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,394 @@
from __future__ import annotations
from typing import Annotated, NoReturn
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.schemas.approval_task import (
ApprovalTaskActionBase,
ApprovalTaskAssignmentAction,
ApprovalTaskBatchApproveCreate,
ApprovalTaskBatchApproveRead,
ApprovalTaskCandidateRead,
ApprovalTaskDecisionAction,
ApprovalTaskDecisionRead,
ApprovalTaskListRead,
ApprovalTaskMutationRead,
ApprovalTaskParticipantsAction,
ApprovalTaskQueueItemRead,
ApprovalTaskReturnAction,
)
from app.schemas.reimbursement import ExpenseClaimRead
from app.services.approval_action_protocol import ApprovalActionConflictError
from app.services.approval_task_access_policy import ApprovalTaskPermissionError
from app.services.approval_task_actions import ApprovalTaskActionService
from app.services.approval_task_batch import ApprovalTaskBatchService
from app.services.approval_task_protocol import ApprovalTaskConflictError
from app.services.approval_task_query import ApprovalTaskQueryService
from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claims import ExpenseClaimService
router = APIRouter(prefix="/approval-tasks")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get("", response_model=ApprovalTaskListRead, summary="查询个人审批任务队列")
def list_approval_tasks(
db: DbSession,
current_user: CurrentUser,
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=200)] = 20,
task_status: Annotated[str, Query(alias="status")] = "pending",
risk_level: str | None = None,
sla_state: str | None = None,
node_key: str | None = None,
task_kind: str | None = None,
batch_eligible: bool | None = None,
assignee: Annotated[str | None, Query(max_length=100)] = None,
sort: str = "priority_desc",
keyword: Annotated[str | None, Query(max_length=100)] = None,
) -> ApprovalTaskListRead:
try:
return ApprovalTaskQueryService(db).list_tasks(
current_user,
page=page,
page_size=page_size,
status=task_status,
risk_level=risk_level,
sla_state=sla_state,
node_key=node_key,
task_kind=task_kind,
batch_eligible=batch_eligible,
assignee=assignee,
sort=sort,
keyword=keyword,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
@router.get(
"/{task_id}",
response_model=ApprovalTaskQueueItemRead,
summary="查询审批任务详情",
)
def get_approval_task(
task_id: str,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskQueueItemRead:
item = ApprovalTaskQueryService(db).get_task(task_id, current_user)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
return item
@router.get(
"/{task_id}/candidates",
response_model=list[ApprovalTaskCandidateRead],
summary="查询当前节点合格处理人",
)
def list_approval_task_candidates(
task_id: str,
db: DbSession,
current_user: CurrentUser,
action: str | None = None,
keyword: Annotated[str | None, Query(max_length=100)] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
) -> list[ApprovalTaskCandidateRead]:
try:
return ApprovalTaskActionService(db).candidates(
task_id,
current_user,
action=action,
keyword=keyword,
limit=limit,
)
except (LookupError, ApprovalTaskPermissionError) as error:
_raise_task_error(error)
@router.post(
"/{task_id}/delegate",
response_model=ApprovalTaskMutationRead,
summary="委托审批任务",
)
def delegate_approval_task(
task_id: str,
payload: ApprovalTaskAssignmentAction,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskMutationRead:
return _assignment_action(task_id, payload, db, current_user, action="delegate")
@router.post(
"/{task_id}/delegation/revoke",
response_model=ApprovalTaskMutationRead,
summary="撤销审批委托",
)
def revoke_approval_task_delegation(
task_id: str,
payload: ApprovalTaskActionBase,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskMutationRead:
try:
return ApprovalTaskActionService(db).revoke_delegation(task_id, current_user, payload)
except (LookupError, ValueError) as error:
_raise_task_error(error)
@router.post(
"/{task_id}/transfer",
response_model=ApprovalTaskMutationRead,
summary="永久转交审批任务",
)
def transfer_approval_task(
task_id: str,
payload: ApprovalTaskAssignmentAction,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskMutationRead:
return _assignment_action(task_id, payload, db, current_user, action="transfer")
@router.post(
"/{task_id}/add-sign",
response_model=ApprovalTaskMutationRead,
summary="发起顺序加签",
)
def add_sign_approval_task(
task_id: str,
payload: ApprovalTaskParticipantsAction,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskMutationRead:
return _participants_action(task_id, payload, db, current_user, action="add_sign")
@router.post(
"/{task_id}/countersign",
response_model=ApprovalTaskMutationRead,
summary="发起并行会签",
)
def countersign_approval_task(
task_id: str,
payload: ApprovalTaskParticipantsAction,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskMutationRead:
return _participants_action(task_id, payload, db, current_user, action="countersign")
@router.post(
"/{task_id}/escalate",
response_model=ApprovalTaskMutationRead,
summary="升级超时审批任务",
)
def escalate_approval_task(
task_id: str,
payload: ApprovalTaskActionBase,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskMutationRead:
try:
return ApprovalTaskActionService(db).escalate(task_id, current_user, payload)
except (LookupError, ValueError) as error:
_raise_task_error(error)
@router.post(
"/{task_id}/approve",
response_model=ApprovalTaskDecisionRead,
summary="通过审批任务",
)
def approve_approval_task(
task_id: str,
payload: ApprovalTaskDecisionAction,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskDecisionRead:
try:
task = _task_or_404(db, task_id, current_user)
if task.task_kind == "root":
claim = ExpenseClaimService(db).approve_claim(
task.claim_id,
current_user,
opinion=payload.opinion or payload.reason,
request_id=payload.request_id,
expected_status=task.claim_status_snapshot,
expected_approval_stage=task.claim_stage_snapshot,
task_id=task.id,
expected_task_version=payload.expected_task_version,
)
if claim is None:
raise LookupError("Claim not found.")
mutation = _mutation_for_request(db, task, payload.request_id)
if isinstance(claim, ExpenseClaimRead):
mutation = mutation.model_copy(update={"replayed": True})
claim_read = (
claim
if isinstance(claim, ExpenseClaimRead)
else ExpenseClaimRead.model_validate(claim)
)
else:
mutation = ApprovalTaskActionService(db).approve_child(
task.id,
current_user,
payload,
)
queue_item = ApprovalTaskQueryService(db).get_task(task.id, current_user)
if queue_item is None:
raise LookupError("Task not found.")
claim_read = queue_item.claim
return ApprovalTaskDecisionRead(mutation=mutation, claim=claim_read)
except (LookupError, ValueError) as error:
_raise_task_error(error)
@router.post(
"/{task_id}/return",
response_model=ApprovalTaskDecisionRead,
summary="退回审批任务关联单据",
)
def return_approval_task(
task_id: str,
payload: ApprovalTaskReturnAction,
db: DbSession,
current_user: CurrentUser,
) -> ApprovalTaskDecisionRead:
try:
task = _task_or_404(db, task_id, current_user)
claim = ExpenseClaimService(db).return_claim(
task.claim_id,
current_user,
reason=payload.reason,
reason_codes=payload.reason_codes,
request_id=payload.request_id,
expected_status=task.claim_status_snapshot,
expected_approval_stage=task.claim_stage_snapshot,
task_id=task.id,
expected_task_version=payload.expected_task_version,
)
if claim is None:
raise LookupError("Claim not found.")
mutation = _mutation_for_request(db, task, payload.request_id)
if isinstance(claim, ExpenseClaimRead):
mutation = mutation.model_copy(update={"replayed": True})
claim_read = (
claim
if isinstance(claim, ExpenseClaimRead)
else ExpenseClaimRead.model_validate(claim)
)
return ApprovalTaskDecisionRead(mutation=mutation, claim=claim_read)
except (LookupError, ValueError) as error:
_raise_task_error(error)
@router.post(
"/batch-approve",
response_model=ApprovalTaskBatchApproveRead,
summary="安全批量审批任务",
)
def batch_approve_tasks(
payload: ApprovalTaskBatchApproveCreate,
current_user: CurrentUser,
) -> ApprovalTaskBatchApproveRead:
return ApprovalTaskBatchService().approve(payload, current_user)
def _assignment_action(
task_id: str,
payload: ApprovalTaskAssignmentAction,
db: Session,
current_user: CurrentUserContext,
*,
action: str,
) -> ApprovalTaskMutationRead:
try:
return ApprovalTaskActionService(db).assign(
task_id,
current_user,
action=action,
payload=payload,
)
except (LookupError, ValueError) as error:
_raise_task_error(error)
def _participants_action(
task_id: str,
payload: ApprovalTaskParticipantsAction,
db: Session,
current_user: CurrentUserContext,
*,
action: str,
) -> ApprovalTaskMutationRead:
try:
return ApprovalTaskActionService(db).add_participants(
task_id,
current_user,
action=action,
payload=payload,
)
except (LookupError, ValueError) as error:
_raise_task_error(error)
def _task_or_404(
db: Session,
task_id: str,
current_user: CurrentUserContext,
) -> ApprovalTask:
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
task = db.scalar(
select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.id == str(task_id).strip(),
)
)
if task is None:
raise LookupError("Task not found.")
return task
def _mutation_for_request(
db: Session,
task: ApprovalTask,
request_id: str,
) -> ApprovalTaskMutationRead:
event = db.scalar(
select(ApprovalTaskEvent).where(
ApprovalTaskEvent.tenant_id == task.tenant_id,
ApprovalTaskEvent.task_id == task.id,
ApprovalTaskEvent.request_id == request_id,
)
)
if event is None:
raise ApprovalTaskConflictError("审批任务审计事件缺失。")
try:
return ApprovalTaskMutationRead.model_validate(event.response_json)
except ValueError as error:
raise ApprovalTaskConflictError("审批任务审计响应快照无效。") from error
def _raise_task_error(error: Exception) -> NoReturn:
if isinstance(error, LookupError):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
if isinstance(error, ApprovalTaskPermissionError):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
if isinstance(error, ExpenseClaimRiskBlockedError):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "APPROVAL_BLOCKED_BY_OPEN_HIGH_RISK", "message": str(error)},
) from error
if isinstance(error, (ApprovalActionConflictError, ApprovalTaskConflictError)):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error

View File

@@ -14,6 +14,8 @@ from app.schemas.reimbursement import (
ExpenseClaimReturnPayload,
)
from app.services.approval_action_protocol import ApprovalActionConflictError
from app.services.approval_task_access_policy import ApprovalTaskPermissionError
from app.services.approval_task_protocol import ApprovalTaskConflictError
from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError
from app.services.expense_claims import ExpenseClaimService
@@ -43,6 +45,10 @@ def _raise_action_error(error: ValueError) -> NoReturn:
) from error
if isinstance(error, ApprovalActionConflictError):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
if isinstance(error, ApprovalTaskPermissionError):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
if isinstance(error, ApprovalTaskConflictError):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
@@ -78,6 +84,8 @@ def return_expense_claim(
request_id=payload.request_id,
expected_status=payload.expected_status,
expected_approval_stage=payload.expected_approval_stage,
task_id=payload.task_id,
expected_task_version=payload.expected_task_version,
)
except ValueError as error:
_raise_action_error(error)
@@ -117,6 +125,8 @@ def approve_expense_claim(
request_id=payload.request_id,
expected_status=payload.expected_status,
expected_approval_stage=payload.expected_approval_stage,
task_id=payload.task_id,
expected_task_version=payload.expected_task_version,
)
except ValueError as error:
_raise_action_error(error)

View File

@@ -20,11 +20,13 @@ from app.schemas.risk_observation import (
RiskObservationListRead,
RiskObservationRead,
)
from app.services.risk_disposition_read_projection import RiskDispositionReadProjection
from app.services.risk_dispositions import (
RiskDispositionConflictError,
RiskDispositionPermissionError,
RiskDispositionService,
RiskDispositionVersionConflictError,
RiskWaiverDecisionPermissionError,
)
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_observations import RiskObservationService
@@ -69,7 +71,16 @@ def list_risk_observations(
limit=limit,
offset=offset,
)
return RiskObservationListRead(items=items, total=total, limit=limit, offset=offset)
projected_items = RiskDispositionReadProjection(db).observation_list_read(
items,
current_user,
)
return RiskObservationListRead(
items=projected_items,
total=total,
limit=limit,
offset=offset,
)
@router.get(
@@ -105,10 +116,14 @@ def list_claim_risk_observations(
) -> list[RiskObservationRead]:
if not RiskObservationAccessPolicy(db).can_read_claim_risks(claim_id, current_user):
raise _not_found()
return RiskObservationService(db).list_claim_observations(
observations = RiskObservationService(db).list_claim_observations(
claim_id,
tenant_id=current_user.tenant_id,
)
return RiskDispositionReadProjection(db).observation_list_read(
observations,
current_user,
)
@router.get(
@@ -123,10 +138,14 @@ def list_execution_log_risk_observations(
current_user: CurrentUser,
) -> list[RiskObservationRead]:
_require_pool_access(db, current_user)
return RiskObservationService(db).list_execution_log_observations(
observations = RiskObservationService(db).list_execution_log_observations(
execution_log_id,
tenant_id=current_user.tenant_id,
)
return RiskDispositionReadProjection(db).observation_list_read(
observations,
current_user,
)
@router.get(
@@ -156,7 +175,10 @@ def get_risk_observation(
status_code=status.HTTP_404_NOT_FOUND,
detail="Risk observation not found",
)
return observation
return RiskDispositionReadProjection(db).observation_read(
observation,
current_user,
)
@router.get(
@@ -178,7 +200,11 @@ def get_risk_observation_disposition(
)
if disposition is None:
raise _not_found("Risk disposition not found")
return disposition
return RiskDispositionReadProjection(db).disposition_read(
disposition,
observation,
current_user,
)
@router.post(
@@ -199,7 +225,19 @@ def execute_risk_disposition_action(
observation_key_or_id,
payload,
)
return mutation.response
observation = _get_observation_or_404(
db,
current_user,
observation_key_or_id,
)
projected_disposition = RiskDispositionReadProjection(db).disposition_snapshot_read(
mutation.response.disposition,
observation,
current_user,
)
return mutation.response.model_copy(
update={"disposition": projected_disposition},
)
@router.post(
@@ -303,6 +341,14 @@ def _execute_action(
"message": "风险处置状态已更新,请刷新证据链后重试。",
},
) from None
except RiskWaiverDecisionPermissionError as error:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "RISK_WAIVER_DECISION_FORBIDDEN",
"message": str(error),
},
) from None
except RiskDispositionPermissionError as error:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,

View File

@@ -6,6 +6,7 @@ from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
from app.api.v1.endpoints.agent_traces import router as agent_traces_router
from app.api.v1.endpoints.analytics import router as analytics_router
from app.api.v1.endpoints.approval_tasks import router as approval_tasks_router
from app.api.v1.endpoints.approval_workbench import router as approval_workbench_router
from app.api.v1.endpoints.attachment_association_jobs import (
router as attachment_association_jobs_router,
@@ -51,6 +52,7 @@ router.include_router(agent_runs_router, tags=["agent-runs"])
router.include_router(agent_traces_router, tags=["agent-traces"])
router.include_router(analytics_router, tags=["analytics"])
router.include_router(approval_workbench_router, tags=["approval-workbench"])
router.include_router(approval_tasks_router, tags=["approval-tasks"])
router.include_router(attachment_association_jobs_router, tags=["attachment-association-jobs"])
router.include_router(audit_logs_router, tags=["audit-logs"])
router.include_router(knowledge_router, tags=["knowledge"])

View File

@@ -14,6 +14,7 @@ from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutco
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.approval import ApprovalRecord
from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.attachment_association_job import AttachmentAssociationJob
from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession
@@ -63,6 +64,8 @@ __all__ = [
"AIDecisionFeedback",
"ApprovalRecord",
"ApprovalActionLedger",
"ApprovalTask",
"ApprovalTaskEvent",
"AttachmentAssociationJob",
"AuditLog",
"AuthSession",

View File

@@ -191,8 +191,54 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
"approval_action_ledgers",
}
),
"20260716_0013": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
"auth_sessions",
"attachment_association_jobs",
"ai_application_preview_decisions",
"ai_decisions",
"ai_decision_feedback",
"memory_entries",
"memory_evidence_links",
"risk_observations",
"risk_observation_feedback",
"risk_dispositions",
"risk_disposition_events",
"few_shot_samples",
"workflow_outcomes",
"approval_action_ledgers",
"approval_tasks",
"approval_task_events",
}
),
"20260716_0014": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
"auth_sessions",
"attachment_association_jobs",
"ai_application_preview_decisions",
"ai_decisions",
"ai_decision_feedback",
"memory_entries",
"memory_evidence_links",
"risk_observations",
"risk_observation_feedback",
"risk_dispositions",
"risk_disposition_events",
"few_shot_samples",
"workflow_outcomes",
"approval_action_ledgers",
"approval_tasks",
"approval_task_events",
}
),
}
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0012"] != MIGRATION_OWNED_TABLES:
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0014"] != MIGRATION_OWNED_TABLES:
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
# 0008 之前这三张表由旧 bootstrap / 风险服务按需创建。迁移前置检查允许
@@ -266,6 +312,8 @@ def _validate_connection(connection: Connection) -> MigrationPreflightState:
"20260716_0010",
"20260716_0011",
"20260716_0012",
"20260716_0013",
"20260716_0014",
}
else frozenset()
)

View File

@@ -8,6 +8,8 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
{
"auth_sessions",
"approval_action_ledgers",
"approval_task_events",
"approval_tasks",
"attachment_association_jobs",
"ai_application_preview_decisions",
"ai_decisions",

View File

@@ -1,9 +1,9 @@
from __future__ import annotations
import threading
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from logging import Logger
import threading
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -16,9 +16,9 @@ from app.db.session import get_session_factory
from app.middleware.logging import AccessLogMiddleware
from app.schemas.common import RootStatusRead
from app.services.agent_foundation import prepare_agent_foundation
from app.services.approval_task_scheduler import approval_task_scheduler
from app.services.digital_employee_reminder_scheduler import digital_employee_reminder_scheduler
from app.services.employee import prepare_employee_directory
from app.services.employee import EmployeeService
from app.services.employee import EmployeeService, prepare_employee_directory
from app.services.employee_profile_scheduler import employee_profile_scheduler
from app.services.finance_dashboard_scheduler import finance_dashboard_scheduler
from app.services.finance_report_scheduler import finance_report_scheduler
@@ -34,7 +34,11 @@ from app.services.user_session_metrics import UserSessionMetricService
def _effective_server_workers(settings: object) -> int:
server_workers = getattr(settings, "server_workers", None)
web_concurrency = getattr(settings, "web_concurrency", None)
workers = web_concurrency if int(server_workers or 1) <= 1 and web_concurrency else server_workers
workers = (
web_concurrency
if int(server_workers or 1) <= 1 and web_concurrency
else server_workers
)
try:
return max(1, int(workers or 1))
except (TypeError, ValueError):
@@ -100,6 +104,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
schedulers_started = _should_start_background_schedulers(settings)
if schedulers_started:
knowledge_index_scheduler.start()
approval_task_scheduler.start()
finance_dashboard_scheduler.start()
employee_profile_scheduler.start()
digital_employee_reminder_scheduler.start()
@@ -118,6 +123,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
)
yield
if schedulers_started:
approval_task_scheduler.shutdown()
finance_report_scheduler.shutdown()
digital_employee_reminder_scheduler.shutdown()
employee_profile_scheduler.shutdown()

View File

@@ -12,6 +12,7 @@ from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutco
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.approval import ApprovalRecord
from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.attachment_association_job import AttachmentAssociationJob
from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession
@@ -57,6 +58,8 @@ __all__ = [
"AIApplicationPreviewDecision",
"ApprovalRecord",
"ApprovalActionLedger",
"ApprovalTask",
"ApprovalTaskEvent",
"AttachmentAssociationJob",
"AuditLog",
"AuthSession",

View File

@@ -0,0 +1,361 @@
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKeyConstraint,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.db.base_class import Base
def _new_id() -> str:
return str(uuid.uuid4())
class ApprovalTask(Base):
"""审批节点的个人工作项投影,不承载 BPMN 流程定义。"""
__tablename__ = "approval_tasks"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_approval_tasks_tenant_id",
),
UniqueConstraint(
"tenant_id",
"node_entry_key",
name="uq_approval_tasks_tenant_node_entry",
),
UniqueConstraint(
"tenant_id",
"node_instance_id",
"assignee_kind",
"assignee_key",
name="uq_approval_tasks_node_participant",
),
ForeignKeyConstraint(
["tenant_id", "parent_task_id"],
["approval_tasks.tenant_id", "approval_tasks.id"],
ondelete="RESTRICT",
name="fk_approval_tasks_tenant_parent",
),
ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
ondelete="RESTRICT",
name="fk_approval_tasks_tenant_expense_case",
),
CheckConstraint(
"task_kind IN ('root', 'add_sign', 'countersign')",
name="ck_approval_tasks_task_kind",
),
CheckConstraint(
"node_key IN ('direct_manager', 'budget_manager', 'finance')",
name="ck_approval_tasks_node_key",
),
CheckConstraint(
"coordination_mode IN ('single', 'sequential_before_owner', 'parallel_all')",
name="ck_approval_tasks_coordination_mode",
),
CheckConstraint(
"owner_kind IN ('employee', 'role')",
name="ck_approval_tasks_owner_kind",
),
CheckConstraint(
"assignee_kind IN ('employee', 'role')",
name="ck_approval_tasks_assignee_kind",
),
CheckConstraint(
"status IN ('waiting', 'pending', 'completed', 'returned', "
"'cancelled', 'superseded')",
name="ck_approval_tasks_status",
),
CheckConstraint(
"decision IS NULL OR decision IN ('approved', 'returned')",
name="ck_approval_tasks_decision",
),
CheckConstraint(
"entered_at_source IN ('workflow_event', 'submitted_at', 'updated_at', 'backfill')",
name="ck_approval_tasks_entered_at_source",
),
CheckConstraint(
"priority_tier IN ('normal', 'high', 'urgent')",
name="ck_approval_tasks_priority_tier",
),
CheckConstraint(
"risk_level IN ('low', 'medium', 'high', 'critical')",
name="ck_approval_tasks_risk_level",
),
CheckConstraint(
"(task_kind = 'root' AND parent_task_id IS NULL AND sequence_order = 0) OR "
"(task_kind IN ('add_sign', 'countersign') "
"AND parent_task_id IS NOT NULL AND sequence_order > 0)",
name="ck_approval_tasks_parent_shape",
),
CheckConstraint(
"(status = 'completed' AND decision = 'approved' "
"AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR "
"(status = 'returned' AND decision = 'returned' "
"AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR "
"(status IN ('cancelled', 'superseded') AND decision IS NULL "
"AND completed_at IS NULL AND cancelled_at IS NOT NULL) OR "
"(status IN ('waiting', 'pending') AND decision IS NULL "
"AND completed_at IS NULL AND cancelled_at IS NULL)",
name="ck_approval_tasks_lifecycle",
),
CheckConstraint(
"status NOT IN ('pending', 'completed', 'returned') OR activated_at IS NOT NULL",
name="ck_approval_tasks_activation",
),
CheckConstraint(
"delegation_expires_at IS NULL OR delegated_by IS NOT NULL",
name="ck_approval_tasks_delegation",
),
CheckConstraint("version >= 1", name="ck_approval_tasks_version"),
CheckConstraint("node_sequence >= 1", name="ck_approval_tasks_node_sequence"),
CheckConstraint("sla_hours_snapshot > 0", name="ck_approval_tasks_sla_hours"),
CheckConstraint("due_at >= entered_at", name="ck_approval_tasks_due_at"),
CheckConstraint("escalation_level >= 0", name="ck_approval_tasks_escalation"),
CheckConstraint(
"priority_score >= 0 AND priority_score <= 100",
name="ck_approval_tasks_priority_score",
),
CheckConstraint(
"open_risk_count >= 0",
name="ck_approval_tasks_open_risk_count",
),
CheckConstraint(
"evidence_completeness >= 0 AND evidence_completeness <= 1",
name="ck_approval_tasks_evidence_completeness",
),
Index(
"uq_approval_tasks_open_root_per_claim",
"tenant_id",
"claim_id",
unique=True,
postgresql_where=text(
"task_kind = 'root' AND status IN ('waiting', 'pending')"
),
).ddl_if(dialect="postgresql"),
Index(
"ix_approval_tasks_personal_inbox",
"tenant_id",
"assignee_kind",
"assignee_key",
"status",
"due_at",
),
Index(
"ix_approval_tasks_tenant_queue",
"tenant_id",
"status",
"priority_score",
"due_at",
),
Index(
"ix_approval_tasks_tenant_claim",
"tenant_id",
"claim_id",
"node_sequence",
),
Index(
"ix_approval_tasks_tenant_node",
"tenant_id",
"node_instance_id",
"sequence_order",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
claim_id: Mapped[str] = mapped_column(String(36), nullable=False)
expense_case_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
node_instance_id: Mapped[str] = mapped_column(String(120), nullable=False)
node_entry_key: Mapped[str] = mapped_column(String(160), nullable=False)
parent_task_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
task_kind: Mapped[str] = mapped_column(String(24), nullable=False)
node_key: Mapped[str] = mapped_column(String(32), nullable=False)
node_label: Mapped[str] = mapped_column(String(120), nullable=False)
node_sequence: Mapped[int] = mapped_column(Integer, nullable=False)
sequence_order: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
coordination_mode: Mapped[str] = mapped_column(String(32), nullable=False)
owner_kind: Mapped[str] = mapped_column(String(20), nullable=False)
owner_key: Mapped[str] = mapped_column(String(120), nullable=False)
owner_employee_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
owner_name: Mapped[str] = mapped_column(String(120), nullable=False)
assignee_kind: Mapped[str] = mapped_column(String(20), nullable=False)
assignee_key: Mapped[str] = mapped_column(String(120), nullable=False)
assignee_employee_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
assignee_name: Mapped[str] = mapped_column(String(120), nullable=False)
delegated_by: Mapped[str | None] = mapped_column(String(120), nullable=True)
delegation_expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
status: Mapped[str] = mapped_column(
String(24), nullable=False, default="waiting", server_default="waiting"
)
decision: Mapped[str | None] = mapped_column(String(20), nullable=True)
opinion: Mapped[str | None] = mapped_column(Text(), nullable=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
claim_status_snapshot: Mapped[str] = mapped_column(String(30), nullable=False)
claim_stage_snapshot: Mapped[str] = mapped_column(String(50), nullable=False)
entered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
entered_at_source: Mapped[str] = mapped_column(String(24), nullable=False)
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
sla_hours_snapshot: Mapped[int] = mapped_column(Integer, nullable=False)
due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
escalation_level: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
escalated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
next_escalation_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
priority_score: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
priority_tier: Mapped[str] = mapped_column(
String(16), nullable=False, default="normal", server_default="normal"
)
priority_reasons_json: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, nullable=False, default=list
)
risk_level: Mapped[str] = mapped_column(
String(16), nullable=False, default="low", server_default="low"
)
open_risk_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
evidence_completeness: Mapped[Decimal] = mapped_column(
Numeric(5, 4), nullable=False, default=Decimal("0"), server_default="0"
)
batch_eligible: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false"
)
batch_block_reasons_json: Mapped[list[str]] = mapped_column(
JSON, nullable=False, default=list
)
projection_updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
claim = relationship(
"ExpenseClaim",
primaryjoin="foreign(ApprovalTask.claim_id) == ExpenseClaim.id",
foreign_keys=[claim_id],
viewonly=True,
)
events = relationship(
"ApprovalTaskEvent",
back_populates="task",
order_by="asc(ApprovalTaskEvent.occurred_at)",
passive_deletes=True,
)
class ApprovalTaskEvent(Base):
"""审批任务的不可变事实记录;所有写动作都以事件响应支持幂等重放。"""
__tablename__ = "approval_task_events"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"actor_id",
"request_id",
name="uq_approval_task_events_actor_request",
),
UniqueConstraint(
"tenant_id",
"task_id",
"result_task_version",
name="uq_approval_task_events_task_version",
),
ForeignKeyConstraint(
["tenant_id", "task_id"],
["approval_tasks.tenant_id", "approval_tasks.id"],
ondelete="RESTRICT",
name="fk_approval_task_events_tenant_task",
),
CheckConstraint(
"length(trim(event_type)) > 0",
name="ck_approval_task_events_type",
),
CheckConstraint(
"actor_type IN ('user', 'system', 'agent', 'service')",
name="ck_approval_task_events_actor_type",
),
CheckConstraint(
"expected_task_version >= 0 AND result_task_version >= 1 "
"AND result_task_version >= expected_task_version",
name="ck_approval_task_events_version",
),
Index(
"ix_approval_task_events_tenant_task_time",
"tenant_id",
"task_id",
"occurred_at",
),
Index(
"ix_approval_task_events_tenant_node_time",
"tenant_id",
"node_instance_id",
"occurred_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
task_id: Mapped[str] = mapped_column(String(36), nullable=False)
node_instance_id: Mapped[str] = mapped_column(String(120), nullable=False)
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
actor_name: Mapped[str] = mapped_column(String(120), nullable=False)
actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
request_id: Mapped[str] = mapped_column(String(120), nullable=False)
expected_task_version: Mapped[int] = mapped_column(Integer, nullable=False)
result_task_version: Mapped[int] = mapped_column(Integer, nullable=False)
payload_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
response_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
approval_action_ledger_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
correlation_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
causation_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
task = relationship("ApprovalTask", back_populates="events")

View File

@@ -46,10 +46,46 @@ class RiskDisposition(Base):
),
CheckConstraint(
"lifecycle_status IN ('open', 'supplement_requested', "
"'remediation_in_progress', 'waiver_requested', 'resolved')",
"'remediation_in_progress', 'waiver_requested', 'waived', "
"'waiver_rejected', 'resolved')",
name="ck_risk_dispositions_lifecycle",
),
CheckConstraint("version >= 0", name="ck_risk_dispositions_version"),
CheckConstraint(
"(waiver_requester_id IS NULL AND waiver_requester_name IS NULL "
"AND waiver_requested_at IS NULL AND waiver_reason IS NULL "
"AND waiver_scope IS NULL AND waiver_expires_at IS NULL) OR "
"(waiver_requester_id IS NOT NULL "
"AND length(trim(waiver_requester_id)) > 0 "
"AND waiver_requester_name IS NOT NULL "
"AND length(trim(waiver_requester_name)) > 0 "
"AND waiver_requested_at IS NOT NULL "
"AND waiver_reason IS NOT NULL AND length(trim(waiver_reason)) > 0 "
"AND waiver_scope IS NOT NULL AND length(trim(waiver_scope)) > 0 "
"AND waiver_expires_at IS NOT NULL "
"AND waiver_expires_at > waiver_requested_at)",
name="ck_risk_dispositions_waiver_request",
),
CheckConstraint(
"(waiver_decision IS NULL AND waiver_decider_id IS NULL "
"AND waiver_decider_name IS NULL AND waiver_decided_at IS NULL "
"AND waiver_decision_reason IS NULL) OR "
"(waiver_decision IN ('approved', 'rejected') "
"AND waiver_requester_id IS NOT NULL "
"AND waiver_decider_id IS NOT NULL "
"AND length(trim(waiver_decider_id)) > 0 "
"AND waiver_decider_name IS NOT NULL "
"AND length(trim(waiver_decider_name)) > 0 "
"AND waiver_decided_at IS NOT NULL "
"AND waiver_decision_reason IS NOT NULL "
"AND length(trim(waiver_decision_reason)) > 0)",
name="ck_risk_dispositions_waiver_decision",
),
CheckConstraint(
"(lifecycle_status != 'waived' OR waiver_decision = 'approved') AND "
"(lifecycle_status != 'waiver_rejected' OR waiver_decision = 'rejected')",
name="ck_risk_dispositions_waiver_lifecycle",
),
ForeignKeyConstraint(
["tenant_id", "observation_id"],
["risk_observations.tenant_id", "risk_observations.id"],
@@ -63,6 +99,12 @@ class RiskDisposition(Base):
"due_at",
),
Index("ix_risk_dispositions_assignee", "tenant_id", "assignee"),
Index(
"ix_risk_dispositions_tenant_waiver_expiry",
"tenant_id",
"lifecycle_status",
"waiver_expires_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
@@ -89,6 +131,26 @@ class RiskDisposition(Base):
assignee: Mapped[str | None] = mapped_column(String(120), nullable=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resolution: Mapped[str | None] = mapped_column(Text(), nullable=True)
waiver_requester_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
waiver_requester_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
waiver_requested_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
waiver_reason: Mapped[str | None] = mapped_column(Text(), nullable=True)
waiver_scope: Mapped[str | None] = mapped_column(Text(), nullable=True)
waiver_expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
waiver_conditions_json: Mapped[list[str]] = mapped_column(
JSON, nullable=False, default=list
)
waiver_decision: Mapped[str | None] = mapped_column(String(20), nullable=True)
waiver_decider_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
waiver_decider_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
waiver_decided_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
waiver_decision_reason: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
@@ -128,7 +190,8 @@ class RiskDispositionEvent(Base):
),
CheckConstraint(
"action IN ('confirm', 'false_positive', 'request_supplement', "
"'start_remediation', 'resolve', 'request_waiver')",
"'start_remediation', 'resolve', 'request_waiver', "
"'approve_waiver', 'reject_waiver')",
name="ck_risk_disposition_events_action",
),
CheckConstraint("version > 0", name="ck_risk_disposition_events_version"),

View File

@@ -0,0 +1,279 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.schemas.reimbursement import ExpenseClaimRead
ApprovalTaskKind = Literal["root", "add_sign", "countersign"]
ApprovalNodeKey = Literal["direct_manager", "budget_manager", "finance"]
ApprovalTaskStatus = Literal[
"waiting",
"pending",
"completed",
"returned",
"cancelled",
"superseded",
]
ApprovalTaskDecision = Literal["approved", "returned"]
ApprovalTaskAction = Literal[
"delegate",
"delegation_revoke",
"transfer",
"add_sign",
"countersign",
"approve",
"return",
"sla_escalate",
]
class ApprovalTaskEventRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
task_id: str
node_instance_id: str
event_type: str
actor_id: str
actor_name: str
actor_type: str
request_id: str
expected_task_version: int
result_task_version: int
payload_json: dict[str, Any] = Field(default_factory=dict)
before_json: dict[str, Any] = Field(default_factory=dict)
after_json: dict[str, Any] = Field(default_factory=dict)
approval_action_ledger_id: str | None = None
business_event_id: str | None = None
correlation_id: str | None = None
causation_id: str | None = None
occurred_at: datetime
class ApprovalTaskRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str
claim_id: str
expense_case_id: str | None = None
node_instance_id: str
node_entry_key: str
parent_task_id: str | None = None
task_kind: ApprovalTaskKind
node_key: ApprovalNodeKey
node_label: str
node_sequence: int
sequence_order: int
coordination_mode: Literal["single", "sequential_before_owner", "parallel_all"]
owner_kind: Literal["employee", "role"]
owner_key: str
owner_employee_id: str | None = None
owner_name: str
assignee_kind: Literal["employee", "role"]
assignee_key: str
assignee_employee_id: str | None = None
assignee_name: str
delegated_by: str | None = None
delegation_expires_at: datetime | None = None
status: ApprovalTaskStatus
decision: ApprovalTaskDecision | None = None
opinion: str | None = None
version: int
claim_status_snapshot: str
claim_stage_snapshot: str
entered_at: datetime
entered_at_source: Literal["workflow_event", "submitted_at", "updated_at", "backfill"]
activated_at: datetime | None = None
sla_hours_snapshot: int
due_at: datetime
completed_at: datetime | None = None
cancelled_at: datetime | None = None
escalation_level: int
escalated_at: datetime | None = None
next_escalation_at: datetime | None = None
priority_score: int
priority_tier: Literal["normal", "high", "urgent"]
priority_reasons_json: list[dict[str, Any]] = Field(default_factory=list)
risk_level: Literal["low", "medium", "high", "critical"]
open_risk_count: int
evidence_completeness: float
batch_eligible: bool
batch_block_reasons_json: list[str] = Field(default_factory=list)
projection_updated_at: datetime
created_at: datetime
updated_at: datetime
can_act: bool = False
available_actions: list[str] = Field(default_factory=list)
read_only_reason: str = ""
class ApprovalTaskMutationRead(BaseModel):
task: ApprovalTaskRead
event: ApprovalTaskEventRead
related_tasks: list[ApprovalTaskRead] = Field(default_factory=list)
replayed: bool = False
class ApprovalTaskQueueItemRead(BaseModel):
task: ApprovalTaskRead
claim: ExpenseClaimRead
class ApprovalTaskListRead(BaseModel):
items: list[ApprovalTaskQueueItemRead] = Field(default_factory=list)
total: int = 0
page: int = 1
page_size: int = 20
total_pages: int = 0
generated_at: datetime
class ApprovalTaskDecisionRead(BaseModel):
mutation: ApprovalTaskMutationRead
claim: ExpenseClaimRead
class ApprovalTaskActionBase(BaseModel):
request_id: str = Field(min_length=8, max_length=120)
expected_task_version: int = Field(ge=1)
reason: str = Field(min_length=2, max_length=500)
@field_validator("request_id", "reason", mode="before")
@classmethod
def normalize_text(cls, value: Any) -> str:
return str(value or "").strip()
class ApprovalTaskAssignmentAction(ApprovalTaskActionBase):
target_employee_id: str = Field(min_length=1, max_length=36)
expires_at: datetime | None = None
@field_validator("target_employee_id", mode="before")
@classmethod
def normalize_employee_id(cls, value: Any) -> str:
return str(value or "").strip()
class ApprovalTaskParticipantsAction(ApprovalTaskActionBase):
participant_employee_ids: list[str] = Field(min_length=1, max_length=10)
@field_validator("participant_employee_ids", mode="before")
@classmethod
def normalize_participants(cls, value: Any) -> list[str]:
if not isinstance(value, list):
return []
normalized = [str(item or "").strip() for item in value]
return [item for item in normalized if item]
@model_validator(mode="after")
def validate_unique_participants(self) -> ApprovalTaskParticipantsAction:
if len(set(self.participant_employee_ids)) != len(self.participant_employee_ids):
raise ValueError("加签或会签参与人不能重复。")
return self
class ApprovalTaskDecisionAction(ApprovalTaskActionBase):
opinion: str | None = Field(default=None, max_length=500)
@field_validator("opinion", mode="before")
@classmethod
def normalize_opinion(cls, value: Any) -> str | None:
normalized = str(value or "").strip()
return normalized or None
class ApprovalTaskReturnAction(ApprovalTaskActionBase):
reason_codes: list[str] = Field(default_factory=list, max_length=10)
@field_validator("reason_codes", mode="before")
@classmethod
def normalize_reason_codes(cls, value: Any) -> list[str]:
if not isinstance(value, list):
return []
return list(
dict.fromkeys(
item
for item in (str(entry or "").strip() for entry in value)
if item
)
)
class ApprovalTaskBatchApproveItem(BaseModel):
task_id: str = Field(min_length=1, max_length=36)
expected_task_version: int = Field(ge=1)
expected_status: str = Field(min_length=1, max_length=30)
expected_approval_stage: str = Field(min_length=1, max_length=50)
opinion: str | None = Field(default=None, max_length=500)
@field_validator(
"task_id",
"expected_status",
"expected_approval_stage",
"opinion",
mode="before",
)
@classmethod
def normalize_batch_text(cls, value: Any) -> Any:
if value is None:
return None
return str(value).strip()
class ApprovalTaskBatchApproveCreate(BaseModel):
batch_request_id: str = Field(min_length=8, max_length=80)
items: list[ApprovalTaskBatchApproveItem] = Field(min_length=1, max_length=20)
@field_validator("batch_request_id", mode="before")
@classmethod
def normalize_batch_request_id(cls, value: Any) -> str:
return str(value or "").strip()
@model_validator(mode="after")
def validate_unique_tasks(self) -> ApprovalTaskBatchApproveCreate:
task_ids = [item.task_id for item in self.items]
if len(set(task_ids)) != len(task_ids):
raise ValueError("批量审批不能包含重复任务。")
return self
class ApprovalTaskBatchItemRead(BaseModel):
task_id: str
claim_id: str | None = None
status: Literal[
"succeeded",
"replayed",
"conflict",
"blocked",
"forbidden",
"failed",
]
code: str
message: str
claim: dict[str, Any] | None = None
class ApprovalTaskBatchApproveRead(BaseModel):
batch_request_id: str
status: Literal["succeeded", "partial", "failed"]
succeeded_count: int = 0
replayed_count: int = 0
conflict_count: int = 0
blocked_count: int = 0
forbidden_count: int = 0
failed_count: int = 0
items: list[ApprovalTaskBatchItemRead] = Field(default_factory=list)
class ApprovalTaskCandidateRead(BaseModel):
employee_id: str
employee_no: str
name: str
email: str
qualified: bool
reason: str = ""

View File

@@ -4,7 +4,7 @@ from datetime import date, datetime
from decimal import Decimal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.services.expense_claim_budget_risk_flags import dedupe_budget_risk_flags
@@ -298,10 +298,26 @@ class ExpenseClaimActionProtocolPayload(BaseModel):
class ExpenseClaimReturnPayload(ExpenseClaimActionProtocolPayload):
reason: str | None = Field(default=None, max_length=500)
reason_codes: list[str] = Field(default_factory=list, max_length=10)
task_id: str | None = Field(default=None, min_length=1, max_length=36)
expected_task_version: int | None = Field(default=None, ge=1)
@model_validator(mode="after")
def validate_task_precondition(self) -> ExpenseClaimReturnPayload:
if (self.task_id is None) != (self.expected_task_version is None):
raise ValueError("task_id 与 expected_task_version 必须同时提供。")
return self
class ExpenseClaimApprovalPayload(ExpenseClaimActionProtocolPayload):
opinion: str | None = Field(default=None, max_length=500)
task_id: str | None = Field(default=None, min_length=1, max_length=36)
expected_task_version: int | None = Field(default=None, ge=1)
@model_validator(mode="after")
def validate_task_precondition(self) -> ExpenseClaimApprovalPayload:
if (self.task_id is None) != (self.expected_task_version is None):
raise ValueError("task_id 与 expected_task_version 必须同时提供。")
return self
class ExpenseClaimPaymentPayload(ExpenseClaimActionProtocolPayload):

View File

@@ -12,6 +12,8 @@ RiskDispositionAction = Literal[
"start_remediation",
"resolve",
"request_waiver",
"approve_waiver",
"reject_waiver",
]
RiskAdjudication = Literal["unreviewed", "confirmed", "false_positive"]
RiskLifecycleStatus = Literal[
@@ -19,6 +21,8 @@ RiskLifecycleStatus = Literal[
"supplement_requested",
"remediation_in_progress",
"waiver_requested",
"waived",
"waiver_rejected",
"resolved",
]
@@ -31,8 +35,20 @@ class RiskDispositionActionCreate(BaseModel):
assignee: str | None = Field(default=None, max_length=120)
due_at: datetime | None = None
resolution: str | None = Field(default=None, max_length=2000)
waiver_reason: str | None = Field(default=None, max_length=2000)
waiver_scope: str | None = Field(default=None, max_length=1000)
waiver_expires_at: datetime | None = None
waiver_conditions: list[str] = Field(default_factory=list, max_length=20)
@field_validator("request_id", "comment", "assignee", "resolution", mode="before")
@field_validator(
"request_id",
"comment",
"assignee",
"resolution",
"waiver_reason",
"waiver_scope",
mode="before",
)
@classmethod
def normalize_text(cls, value: Any) -> Any:
if value is None:
@@ -40,14 +56,42 @@ class RiskDispositionActionCreate(BaseModel):
normalized = str(value).strip()
return normalized or None
@field_validator("waiver_conditions", mode="before")
@classmethod
def normalize_waiver_conditions(cls, value: Any) -> list[str]:
if not isinstance(value, list):
return []
normalized = [str(item or "").strip() for item in value]
if any(len(item) > 500 for item in normalized):
raise ValueError("单条豁免条件不能超过 500 个字符")
return list(dict.fromkeys(item for item in normalized if item))
@model_validator(mode="after")
def validate_action_fields(self) -> RiskDispositionActionCreate:
if self.action == "resolve" and not self.resolution:
raise ValueError("resolve 动作必须填写 resolution")
if self.action in {"false_positive", "request_supplement", "request_waiver"} and not (
self.comment
):
if self.action in {"false_positive", "request_supplement"} and not self.comment:
raise ValueError(f"{self.action} 动作必须填写 comment")
if self.action == "request_waiver" and not all(
(self.waiver_reason, self.waiver_scope, self.waiver_expires_at)
):
raise ValueError("request_waiver 必须填写原因、范围和有效期")
if self.action in {"approve_waiver", "reject_waiver"} and not self.comment:
raise ValueError(f"{self.action} 动作必须填写 comment")
if self.action in {"approve_waiver", "reject_waiver"} and any(
value is not None for value in (self.assignee, self.due_at, self.resolution)
):
raise ValueError("豁免决定动作不能同时修改负责人、截止时间或解决说明")
waiver_fields_present = any(
(
self.waiver_reason,
self.waiver_scope,
self.waiver_expires_at,
self.waiver_conditions,
)
)
if self.action != "request_waiver" and waiver_fields_present:
raise ValueError("只有 request_waiver 可以填写豁免申请字段")
if self.action in {"confirm", "false_positive"} and any(
value is not None for value in (self.assignee, self.due_at, self.resolution)
):
@@ -85,6 +129,20 @@ class RiskDispositionRead(BaseModel):
assignee: str | None
due_at: datetime | None
resolution: str | None
waiver_requester_id: str | None = None
waiver_requester_name: str | None = None
waiver_requested_at: datetime | None = None
waiver_reason: str | None = None
waiver_scope: str | None = None
waiver_expires_at: datetime | None = None
waiver_conditions_json: list[str] = Field(default_factory=list)
waiver_decision: Literal["approved", "rejected"] | None = None
waiver_decider_id: str | None = None
waiver_decider_name: str | None = None
waiver_decided_at: datetime | None = None
waiver_decision_reason: str | None = None
available_actions: list[RiskDispositionAction] = Field(default_factory=list)
read_only_reason: str = ""
created_at: datetime
updated_at: datetime
events: list[RiskDispositionEventRead] = Field(default_factory=list)

View File

@@ -5,7 +5,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.risk_disposition import RiskDispositionRead
from app.schemas.risk_disposition import RiskDispositionAction, RiskDispositionRead
RiskObservationStatus = Literal[
"pending_review",
@@ -92,6 +92,8 @@ class RiskObservationRead(BaseModel):
updated_at: datetime
feedback_items: list[RiskObservationFeedbackRead] = Field(default_factory=list)
disposition: RiskDispositionRead | None = None
available_actions: list[RiskDispositionAction] = Field(default_factory=list)
read_only_reason: str = ""
class RiskObservationListRead(BaseModel):

View File

@@ -0,0 +1,266 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import CurrentUserContext
from app.models.approval_task import ApprovalTask
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
_OPEN_STATUSES = {"pending", "waiting"}
_ACTIVE_EMPLOYMENT_STATUSES = {"", "active", "employed", "在职", "正常"}
class ApprovalTaskPermissionError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ApprovalTaskPermission:
can_read: bool
can_act: bool
available_actions: tuple[str, ...]
read_only_reason: str = ""
class ApprovalTaskAccessPolicy:
"""让列表可见性、任务管理和最终动作共用同一授权判断。"""
def __init__(self, db: Session) -> None:
self.db = db
self.claim_policy = ExpenseClaimAccessPolicy(db)
def evaluate(
self,
task: ApprovalTask,
current_user: CurrentUserContext,
*,
claim: ExpenseClaim | None = None,
) -> ApprovalTaskPermission:
if task.tenant_id != ExpenseClaimTenantScopeMixin.normalize_tenant_id(
current_user.tenant_id
):
return ApprovalTaskPermission(False, False, (), "任务不在当前租户范围内。")
assigned = self._matches_assignment(task, current_user, owner=False)
owner = self._matches_assignment(task, current_user, owner=True)
admin = bool(current_user.is_admin)
can_read = admin or assigned or owner
if not can_read:
return ApprovalTaskPermission(False, False, (), "当前用户不在该任务参与范围内。")
available: list[str] = []
read_only_reason = ""
can_act = False
if task.status == "waiting":
read_only_reason = "当前任务正在等待加签或会签完成。"
elif task.status != "pending":
read_only_reason = "该任务已经处理,当前仅可查看审计记录。"
elif assigned:
if claim is not None and self.claim_policy.is_claim_owned_by_current_user(
claim,
current_user,
):
read_only_reason = "申请人不能审批自己的费用单。"
else:
can_act = True
available.append("approve")
available.append("return")
if task.task_kind == "root":
available.extend(("delegate", "transfer", "add_sign", "countersign"))
elif owner and task.delegated_by:
available.append("delegation_revoke")
read_only_reason = "任务已委托,原责任人只能撤销委托。"
elif admin:
available.extend(("transfer", "sla_escalate"))
read_only_reason = "管理员可转交或升级任务,但不能凭查看权限直接审批。"
else:
read_only_reason = "当前用户不是任务的有效处理人。"
if admin and "sla_escalate" not in available and task.status in _OPEN_STATUSES:
available.append("sla_escalate")
return ApprovalTaskPermission(
can_read=True,
can_act=can_act,
available_actions=tuple(dict.fromkeys(available)),
read_only_reason=read_only_reason,
)
def require_read(
self,
task: ApprovalTask,
current_user: CurrentUserContext,
*,
claim: ExpenseClaim | None = None,
) -> ApprovalTaskPermission:
permission = self.evaluate(task, current_user, claim=claim)
if not permission.can_read:
raise LookupError("Approval task not found.")
return permission
def require_action(
self,
task: ApprovalTask,
current_user: CurrentUserContext,
*,
action: str,
claim: ExpenseClaim,
) -> ApprovalTaskPermission:
permission = self.require_read(task, current_user, claim=claim)
if action not in permission.available_actions:
raise ApprovalTaskPermissionError(
permission.read_only_reason or "当前用户无权执行该审批任务动作。"
)
if action in {"approve", "return"} and not permission.can_act:
raise ApprovalTaskPermissionError(
permission.read_only_reason or "当前用户不是任务处理人。"
)
return permission
def resolve_and_validate_target(
self,
*,
task: ApprovalTask,
claim: ExpenseClaim,
target_employee_id: str,
) -> Employee:
target = self.db.scalar(
select(Employee)
.options(
selectinload(Employee.roles),
selectinload(Employee.organization_unit),
)
.where(Employee.id == str(target_employee_id or "").strip())
)
if target is None:
raise ValueError("目标审批人不存在。")
employment_status = str(target.employment_status or "").strip().lower()
if employment_status not in _ACTIVE_EMPLOYMENT_STATUSES:
raise ValueError("目标审批人不是在职员工。")
if str(claim.employee_id or "").strip() == target.id:
raise ValueError("申请人不能成为自己的审批人。")
if not self._target_qualified_for_node(target, task=task, claim=claim):
raise ValueError("目标员工不具备当前审批节点的处理资格。")
return target
def candidate_employees(
self,
*,
task: ApprovalTask,
claim: ExpenseClaim,
) -> list[tuple[Employee, bool, str]]:
employees = list(
self.db.scalars(
select(Employee)
.options(
selectinload(Employee.roles),
selectinload(Employee.organization_unit),
)
.order_by(Employee.name.asc(), Employee.employee_no.asc())
).all()
)
rows: list[tuple[Employee, bool, str]] = []
for employee in employees:
if str(claim.employee_id or "").strip() == employee.id:
rows.append((employee, False, "申请人不能审批自己的费用单。"))
continue
status = str(employee.employment_status or "").strip().lower()
if status not in _ACTIVE_EMPLOYMENT_STATUSES:
rows.append((employee, False, "员工当前不在职。"))
continue
qualified = self._target_qualified_for_node(employee, task=task, claim=claim)
rows.append((employee, qualified, "" if qualified else "不具备当前节点资格。"))
return rows
def actor_id(self, current_user: CurrentUserContext) -> str:
employee = self.claim_policy.resolve_current_employee(current_user)
return str(
(employee.id if employee is not None else "")
or current_user.employee_id
or current_user.username
).strip()
def _matches_assignment(
self,
task: ApprovalTask,
current_user: CurrentUserContext,
*,
owner: bool,
) -> bool:
if (
not owner
and task.delegated_by
and task.delegation_expires_at is not None
and _as_utc(task.delegation_expires_at) <= datetime.now(UTC)
):
return False
kind = str(task.owner_kind if owner else task.assignee_kind).strip().lower()
key = str(task.owner_key if owner else task.assignee_key).strip().casefold()
employee_id = str(
(task.owner_employee_id if owner else task.assignee_employee_id) or ""
).strip()
if kind == "role":
return key in self.claim_policy.normalize_role_codes(current_user)
current_employee = self.claim_policy.resolve_current_employee(current_user)
identities = {
str(current_user.username or "").strip().casefold(),
str(current_user.employee_id or "").strip().casefold(),
str(current_user.employee_no or "").strip().casefold(),
}
if current_employee is not None:
identities.update(
{
str(current_employee.id or "").strip().casefold(),
str(current_employee.name or "").strip().casefold(),
str(current_employee.employee_no or "").strip().casefold(),
str(current_employee.email or "").strip().casefold(),
}
)
identities.discard("")
return key in identities or bool(employee_id and employee_id.casefold() in identities)
def _target_qualified_for_node(
self,
target: Employee,
*,
task: ApprovalTask,
claim: ExpenseClaim,
) -> bool:
role_codes = {
str(role.role_code or "").strip().lower()
for role in list(target.roles or [])
if str(role.role_code or "").strip()
}
if task.node_key == "finance":
return "finance" in role_codes
if task.node_key == "budget_manager":
same_department = bool(
claim.department_id
and target.organization_unit_id
and str(claim.department_id) == str(target.organization_unit_id)
) or (
bool(claim.department_name and target.organization_unit)
and str(claim.department_name).strip().casefold()
== str(target.organization_unit.name or "").strip().casefold()
)
grade = str(target.grade or "").strip().upper()
grade_level = int(grade[1:]) if grade.startswith("P") and grade[1:].isdigit() else 0
return same_department and grade_level >= 8 and bool(
role_codes & {"budget_monitor", "executive"}
)
if claim.employee is not None and claim.employee.manager_id == target.id:
return True
return bool(role_codes & {"manager", "approver", "executive"})
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -0,0 +1,762 @@
from __future__ import annotations
import hashlib
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.approval_task import ApprovalTask
from app.models.financial_record import ExpenseClaim
from app.schemas.approval_task import (
ApprovalTaskActionBase,
ApprovalTaskAssignmentAction,
ApprovalTaskCandidateRead,
ApprovalTaskDecisionAction,
ApprovalTaskMutationRead,
ApprovalTaskParticipantsAction,
)
from app.services.approval_task_access_policy import (
ApprovalTaskAccessPolicy,
ApprovalTaskPermissionError,
)
from app.services.approval_task_protocol import (
ApprovalTaskConflictError,
ApprovalTaskEventProtocol,
ApprovalTaskVersionConflictError,
payload_fingerprint,
task_state,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
class ApprovalTaskActionService:
"""审批任务辅助动作;不改变费用单状态机。"""
def __init__(self, db: Session) -> None:
self.db = db
self.access = ApprovalTaskAccessPolicy(db)
self.events = ApprovalTaskEventProtocol(db)
def candidates(
self,
task_id: str,
current_user: CurrentUserContext,
*,
action: str | None = None,
keyword: str | None = None,
limit: int = 50,
) -> list[ApprovalTaskCandidateRead]:
task, claim = self._read_context(task_id, current_user)
permission = self.access.require_read(task, current_user, claim=claim)
normalized_action = str(action or "").strip()
if normalized_action and normalized_action not in permission.available_actions:
raise ApprovalTaskPermissionError(
permission.read_only_reason or "当前用户无权为该任务选择处理人。"
)
normalized_keyword = str(keyword or "").strip().casefold()
rows = [
ApprovalTaskCandidateRead(
employee_id=employee.id,
employee_no=str(employee.employee_no or ""),
name=str(employee.name or ""),
email=str(employee.email or ""),
qualified=qualified,
reason=reason,
)
for employee, qualified, reason in self.access.candidate_employees(
task=task,
claim=claim,
)
]
if normalized_keyword:
rows = [
row
for row in rows
if normalized_keyword
in " ".join((row.name, row.employee_no, row.email)).casefold()
]
return rows[: min(max(int(limit), 1), 100)]
def assign(
self,
task_id: str,
current_user: CurrentUserContext,
*,
action: str,
payload: ApprovalTaskAssignmentAction,
) -> ApprovalTaskMutationRead:
if action not in {"delegate", "transfer"}:
raise ValueError("不支持的任务分配动作。")
event_type = "task_delegated" if action == "delegate" else "task_transferred"
actor_id = self.access.actor_id(current_user)
event_payload = payload.model_dump(mode="json", exclude={"request_id"})
fingerprint = payload_fingerprint(
task_id=task_id,
actor_id=actor_id,
event_type=event_type,
payload=event_payload,
)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
try:
task, claim = self._lock_context(task_id, current_user)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
self.db.commit()
return replay
self._ensure_expected_version(task, payload.expected_task_version)
permission = self.access.require_action(
task,
current_user,
action=action,
claim=claim,
)
target = self.access.resolve_and_validate_target(
task=task,
claim=claim,
target_employee_id=payload.target_employee_id,
)
if target.id == task.assignee_employee_id:
raise ApprovalTaskConflictError("目标员工已经是当前任务处理人。")
if action == "delegate" and task.delegated_by:
raise ApprovalTaskConflictError("当前任务已有委托,请先撤销委托或执行转交。")
if action == "delegate" and payload.expires_at is not None:
if _as_utc(payload.expires_at) <= datetime.now(UTC):
raise ValueError("委托失效时间必须晚于当前时间。")
if action == "transfer" and payload.expires_at is not None:
raise ValueError("永久转交不能设置委托失效时间。")
before = task_state(task)
expected = task.version
task.assignee_kind = "employee"
task.assignee_key = target.id
task.assignee_employee_id = target.id
task.assignee_name = str(target.name or target.employee_no)
if action == "delegate":
task.delegated_by = actor_id
task.delegation_expires_at = payload.expires_at
else:
task.owner_kind = "employee"
task.owner_key = target.id
task.owner_employee_id = target.id
task.owner_name = str(target.name or target.employee_no)
task.delegated_by = None
task.delegation_expires_at = None
task.version += 1
task.updated_at = datetime.now(UTC)
permission = self.access.evaluate(task, current_user, claim=claim)
_, response = self.events.append_event(
task=task,
event_type=event_type,
actor_id=actor_id,
actor_name=str(current_user.name or current_user.username),
actor_type="user",
request_id=payload.request_id,
expected_task_version=expected,
payload=event_payload,
before_json=before,
permission=permission,
)
self.db.commit()
return response
except IntegrityError as error:
self.db.rollback()
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
raise ApprovalTaskConflictError("审批任务已被并发修改,请刷新后重试。") from error
except Exception:
self.db.rollback()
raise
def revoke_delegation(
self,
task_id: str,
current_user: CurrentUserContext,
payload: ApprovalTaskActionBase,
) -> ApprovalTaskMutationRead:
return self._simple_mutation(
task_id,
current_user,
action="delegation_revoke",
payload=payload,
mutate=self._apply_delegation_revoke,
)
def escalate(
self,
task_id: str,
current_user: CurrentUserContext,
payload: ApprovalTaskActionBase,
) -> ApprovalTaskMutationRead:
return self._simple_mutation(
task_id,
current_user,
action="sla_escalate",
payload=payload,
mutate=self._apply_escalation,
)
def add_participants(
self,
task_id: str,
current_user: CurrentUserContext,
*,
action: str,
payload: ApprovalTaskParticipantsAction,
) -> ApprovalTaskMutationRead:
if action not in {"add_sign", "countersign"}:
raise ValueError("不支持的参与人动作。")
event_type = "add_sign_started" if action == "add_sign" else "countersign_started"
actor_id = self.access.actor_id(current_user)
event_payload = payload.model_dump(mode="json", exclude={"request_id"})
fingerprint = payload_fingerprint(
task_id=task_id,
actor_id=actor_id,
event_type=event_type,
payload=event_payload,
)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
try:
root, claim = self._lock_context(task_id, current_user)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
self.db.commit()
return replay
self._ensure_expected_version(root, payload.expected_task_version)
self.access.require_action(root, current_user, action=action, claim=claim)
if root.task_kind != "root" or root.status != "pending":
raise ApprovalTaskConflictError("只有待处理的根任务可以发起加签或会签。")
existing_children = self.db.scalar(
select(ApprovalTask.id).where(
ApprovalTask.tenant_id == root.tenant_id,
ApprovalTask.parent_task_id == root.id,
ApprovalTask.status.in_(("waiting", "pending")),
)
)
if existing_children is not None:
raise ApprovalTaskConflictError("当前节点已有未完成的加签或会签任务。")
participants = [
self.access.resolve_and_validate_target(
task=root,
claim=claim,
target_employee_id=employee_id,
)
for employee_id in payload.participant_employee_ids
]
occupied = {
str(root.owner_employee_id or ""),
str(root.assignee_employee_id or ""),
}
if any(employee.id in occupied for employee in participants):
raise ValueError("加签或会签参与人不能与当前责任人重复。")
now = datetime.now(UTC)
before = task_state(root)
expected = root.version
root.status = "waiting"
root.coordination_mode = (
"sequential_before_owner" if action == "add_sign" else "parallel_all"
)
root.version += 1
root.updated_at = now
children = [
self._new_child(
root,
claim=claim,
employee=employee,
task_kind=action,
sequence_order=index,
active=(action == "countersign" or index == 1),
now=now,
)
for index, employee in enumerate(participants, start=1)
]
self.db.add_all(children)
self.db.flush()
for index, child in enumerate(children, start=1):
self.events.append_event(
task=child,
event_type="node_entered",
actor_id="system",
actor_name="审批任务引擎",
actor_type="system",
request_id=_derived_request(payload.request_id, f"child:{index}"),
expected_task_version=0,
payload={
"parent_task_id": root.id,
"task_kind": action,
"sequence_order": index,
},
before_json={},
occurred_at=now,
)
event, response = self.events.append_event(
task=root,
event_type=event_type,
actor_id=actor_id,
actor_name=str(current_user.name or current_user.username),
actor_type="user",
request_id=payload.request_id,
expected_task_version=expected,
payload=event_payload,
before_json=before,
related_tasks=children,
)
event.response_json = response.model_dump(mode="json")
self.db.commit()
return response
except IntegrityError as error:
self.db.rollback()
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
raise ApprovalTaskConflictError("审批任务已被并发修改,请刷新后重试。") from error
except Exception:
self.db.rollback()
raise
def approve_child(
self,
task_id: str,
current_user: CurrentUserContext,
payload: ApprovalTaskDecisionAction,
) -> ApprovalTaskMutationRead:
actor_id = self.access.actor_id(current_user)
event_payload = payload.model_dump(mode="json", exclude={"request_id"})
fingerprint = payload_fingerprint(
task_id=task_id,
actor_id=actor_id,
event_type="task_approved",
payload=event_payload,
)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
try:
task, claim = self._lock_context(task_id, current_user)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
self.db.commit()
return replay
if task.task_kind == "root":
raise ApprovalTaskConflictError("根任务请使用单据审批动作。")
self._ensure_expected_version(task, payload.expected_task_version)
self.access.require_action(task, current_user, action="approve", claim=claim)
before = task_state(task)
expected = task.version
now = datetime.now(UTC)
task.status = "completed"
task.decision = "approved"
task.opinion = payload.opinion or payload.reason
task.completed_at = now
task.version += 1
task.updated_at = now
related = self._advance_node_family(task, claim=claim, now=now)
event, response = self.events.append_event(
task=task,
event_type="task_approved",
actor_id=actor_id,
actor_name=str(current_user.name or current_user.username),
actor_type="user",
request_id=payload.request_id,
expected_task_version=expected,
payload=event_payload,
before_json=before,
related_tasks=related,
occurred_at=now,
)
event.response_json = response.model_dump(mode="json")
self.db.commit()
return response
except IntegrityError as error:
self.db.rollback()
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
raise ApprovalTaskConflictError("审批任务已被并发修改,请刷新后重试。") from error
except Exception:
self.db.rollback()
raise
def _simple_mutation(
self,
task_id: str,
current_user: CurrentUserContext,
*,
action: str,
payload: ApprovalTaskActionBase,
mutate,
) -> ApprovalTaskMutationRead:
actor_id = self.access.actor_id(current_user)
event_payload = payload.model_dump(mode="json", exclude={"request_id"})
fingerprint = payload_fingerprint(
task_id=task_id,
actor_id=actor_id,
event_type=action,
payload=event_payload,
)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
try:
task, claim = self._lock_context(task_id, current_user)
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
self.db.commit()
return replay
self._ensure_expected_version(task, payload.expected_task_version)
self.access.require_action(task, current_user, action=action, claim=claim)
before = task_state(task)
expected = task.version
mutate(task, actor_id=actor_id)
task.version += 1
task.updated_at = datetime.now(UTC)
_, response = self.events.append_event(
task=task,
event_type=action,
actor_id=actor_id,
actor_name=str(current_user.name or current_user.username),
actor_type="user",
request_id=payload.request_id,
expected_task_version=expected,
payload=event_payload,
before_json=before,
)
self.db.commit()
return response
except IntegrityError as error:
self.db.rollback()
replay = self.events.find_replay(
tenant_id=self._tenant(current_user),
actor_id=actor_id,
request_id=payload.request_id,
fingerprint=fingerprint,
)
if replay is not None:
return replay
raise ApprovalTaskConflictError("审批任务已被并发修改,请刷新后重试。") from error
except Exception:
self.db.rollback()
raise
def _read_context(
self,
task_id: str,
current_user: CurrentUserContext,
) -> tuple[ApprovalTask, ExpenseClaim]:
tenant = self._tenant(current_user)
task = self.db.scalar(
select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.id == str(task_id).strip(),
)
)
if task is None:
raise LookupError("Approval task not found.")
claim = self.db.scalar(
select(ExpenseClaim).where(
ExpenseClaim.id == task.claim_id,
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant),
)
)
if claim is None:
raise LookupError("Approval task not found.")
return task, claim
def _lock_context(
self,
task_id: str,
current_user: CurrentUserContext,
) -> tuple[ApprovalTask, ExpenseClaim]:
tenant = self._tenant(current_user)
locator = self.db.execute(
select(ApprovalTask.claim_id).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.id == str(task_id).strip(),
)
).one_or_none()
if locator is None:
raise LookupError("Approval task not found.")
claim_statement = select(ExpenseClaim).where(
ExpenseClaim.id == locator[0],
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant),
)
task_statement = select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.id == str(task_id).strip(),
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
claim_statement = claim_statement.with_for_update()
task_statement = task_statement.with_for_update()
claim = self.db.scalar(claim_statement.execution_options(populate_existing=True))
task = self.db.scalar(task_statement.execution_options(populate_existing=True))
if claim is None or task is None or task.claim_id != claim.id:
raise LookupError("Approval task not found.")
return task, claim
def _new_child(
self,
root: ApprovalTask,
*,
claim: ExpenseClaim,
employee,
task_kind: str,
sequence_order: int,
active: bool,
now: datetime,
) -> ApprovalTask:
return ApprovalTask(
id=str(uuid.uuid4()),
tenant_id=root.tenant_id,
claim_id=root.claim_id,
expense_case_id=root.expense_case_id,
node_instance_id=root.node_instance_id,
node_entry_key=f"{root.id}:{task_kind}:{sequence_order}:{employee.id}",
parent_task_id=root.id,
task_kind=task_kind,
node_key=root.node_key,
node_label=root.node_label,
node_sequence=root.node_sequence,
sequence_order=sequence_order,
coordination_mode=root.coordination_mode,
owner_kind="employee",
owner_key=employee.id,
owner_employee_id=employee.id,
owner_name=str(employee.name or employee.employee_no),
assignee_kind="employee",
assignee_key=employee.id,
assignee_employee_id=employee.id,
assignee_name=str(employee.name or employee.employee_no),
status="pending" if active else "waiting",
decision=None,
version=1,
claim_status_snapshot=str(claim.status or "").strip().lower(),
claim_stage_snapshot=str(claim.approval_stage or "").strip(),
entered_at=now,
entered_at_source="workflow_event",
activated_at=now if active else None,
sla_hours_snapshot=root.sla_hours_snapshot,
due_at=now + timedelta(hours=root.sla_hours_snapshot),
escalation_level=0,
next_escalation_at=root.next_escalation_at,
priority_score=root.priority_score,
priority_tier=root.priority_tier,
priority_reasons_json=list(root.priority_reasons_json or []),
risk_level=root.risk_level,
open_risk_count=root.open_risk_count,
evidence_completeness=root.evidence_completeness,
batch_eligible=False,
batch_block_reasons_json=["加签或会签任务不能批量审批。"],
projection_updated_at=root.projection_updated_at,
created_at=now,
updated_at=now,
)
def _advance_node_family(
self,
task: ApprovalTask,
*,
claim: ExpenseClaim,
now: datetime,
) -> list[ApprovalTask]:
root_statement = select(ApprovalTask).where(
ApprovalTask.tenant_id == task.tenant_id,
ApprovalTask.id == task.parent_task_id,
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
root_statement = root_statement.with_for_update()
root = self.db.scalar(root_statement)
if root is None or root.status != "waiting":
raise ApprovalTaskConflictError("加签或会签根任务状态异常。")
related: list[ApprovalTask] = []
if task.task_kind == "add_sign":
next_child = self.db.scalar(
select(ApprovalTask)
.where(
ApprovalTask.tenant_id == task.tenant_id,
ApprovalTask.parent_task_id == root.id,
ApprovalTask.status == "waiting",
)
.order_by(ApprovalTask.sequence_order.asc())
.limit(1)
)
if next_child is not None:
self._activate_related(
next_child,
claim=claim,
now=now,
event_type="task_activated",
)
related.append(next_child)
return related
elif self.db.scalar(
select(ApprovalTask.id).where(
ApprovalTask.tenant_id == task.tenant_id,
ApprovalTask.parent_task_id == root.id,
ApprovalTask.status.in_(("waiting", "pending")),
ApprovalTask.id != task.id,
)
) is not None:
return related
self._activate_related(
root,
claim=claim,
now=now,
event_type="root_reactivated",
coordination_mode="single",
)
related.append(root)
return related
def _activate_related(
self,
task: ApprovalTask,
*,
claim: ExpenseClaim,
now: datetime,
event_type: str,
coordination_mode: str | None = None,
) -> None:
before = task_state(task)
expected = task.version
task.status = "pending"
if coordination_mode is not None:
task.coordination_mode = coordination_mode
task.activated_at = now
task.version += 1
task.claim_status_snapshot = str(claim.status or "").strip().lower()
task.claim_stage_snapshot = str(claim.approval_stage or "").strip()
task.updated_at = now
self.events.append_event(
task=task,
event_type=event_type,
actor_id="system",
actor_name="审批任务引擎",
actor_type="system",
request_id=f"activate:{task.id}:{task.version}",
expected_task_version=expected,
payload={"reason": "participant_tasks_completed"},
before_json=before,
occurred_at=now,
)
@staticmethod
def _apply_delegation_revoke(task: ApprovalTask, *, actor_id: str) -> None:
if not task.delegated_by:
raise ApprovalTaskConflictError("当前任务没有可撤销的委托。")
task.assignee_kind = task.owner_kind
task.assignee_key = task.owner_key
task.assignee_employee_id = task.owner_employee_id
task.assignee_name = task.owner_name
task.delegated_by = None
task.delegation_expires_at = None
@staticmethod
def _apply_escalation(task: ApprovalTask, *, actor_id: str) -> None:
now = datetime.now(UTC)
if task.escalation_level >= 3:
raise ApprovalTaskConflictError("该任务已经达到最高升级等级。")
task.escalation_level += 1
task.escalated_at = now
task.next_escalation_at = (
None if task.escalation_level >= 3 else now + timedelta(hours=4)
)
task.priority_score = min(100, int(task.priority_score) + 10)
task.priority_tier = "urgent" if task.priority_score >= 65 else "high"
task.priority_reasons_json = [
*list(task.priority_reasons_json or []),
{
"code": f"sla_escalated_l{task.escalation_level}",
"label": f"审批超时已升级至 L{task.escalation_level}",
"weight": 10,
"tone": "danger",
},
]
@staticmethod
def _ensure_expected_version(task: ApprovalTask, expected: int) -> None:
if task.version != expected:
raise ApprovalTaskVersionConflictError(task.version)
@staticmethod
def _tenant(current_user: CurrentUserContext) -> str:
return ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def _derived_request(request_id: str, suffix: str) -> str:
value = f"{request_id}:{suffix}"
if len(value) <= 120:
return value
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()
return f"derived:{digest}"

View File

@@ -0,0 +1,761 @@
from __future__ import annotations
import hashlib
import uuid
from collections.abc import Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.models.approval_task import ApprovalTask
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.services.approval_task_projection import (
DEFAULT_APPROVAL_SLA_HOURS,
ApprovalTaskProjectionService,
)
from app.services.approval_task_protocol import ApprovalTaskEventProtocol
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_status_registry import normalize_expense_claim_state
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claim_workflow_constants import (
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
DEFAULT_BACKFILL_BATCH_SIZE = 100
MAX_BACKFILL_BATCH_SIZE = 1000
_PENDING_STATUS_VALUES = frozenset(
{
"submitted",
"review",
"pending_review",
"approving",
"manager_review",
"budget_review",
"finance_review",
"in_approval",
"under_review",
}
)
_OPEN_TASK_STATUSES = frozenset({"waiting", "pending"})
_ACTIVE_EMPLOYMENT_STATUSES = frozenset({"", "active", "employed", "在职", "正常"})
_NODE_BY_STAGE = {
DIRECT_MANAGER_APPROVAL_STAGE: ("direct_manager", "直属领导审批", 1),
BUDGET_MANAGER_APPROVAL_STAGE: ("budget_manager", "预算管理者审批", 2),
FINANCE_APPROVAL_STAGE: ("finance", "财务审批", 3),
}
class ApprovalTaskBackfillDisposition(StrEnum):
ELIGIBLE = "eligible"
CREATED = "created"
EXISTING = "existing"
UNSUPPORTED_STAGE = "unsupported_stage"
ASSIGNEE_UNRESOLVED = "assignee_unresolved"
TENANT_CONFLICT = "tenant_conflict"
@dataclass(frozen=True, slots=True)
class ApprovalTaskBackfillCursor:
created_at: datetime
claim_id: str
@dataclass(frozen=True, slots=True)
class ApprovalTaskBackfillItem:
claim_id: str
claim_no: str
disposition: ApprovalTaskBackfillDisposition
node_key: str = ""
entered_at: datetime | None = None
entered_at_source: str = ""
assignee_key: str = ""
task_id: str | None = None
reason: str = ""
@dataclass(frozen=True, slots=True)
class ApprovalTaskBackfillPreview:
tenant_id: str
inspected: int
eligible: int
existing: int
skipped: int
has_more: bool
next_cursor: ApprovalTaskBackfillCursor | None
items: tuple[ApprovalTaskBackfillItem, ...]
@dataclass(frozen=True, slots=True)
class ApprovalTaskBackfillResult:
tenant_id: str
run_id: str
inspected: int
created: int
existing: int
skipped: int
has_more: bool
next_cursor: ApprovalTaskBackfillCursor | None
items: tuple[ApprovalTaskBackfillItem, ...]
@dataclass(frozen=True, slots=True)
class _Assignment:
kind: str
key: str
employee_id: str | None
name: str
@dataclass(slots=True)
class _BatchContext:
links_by_claim_id: dict[str, ExpenseCaseLink]
events_by_claim_id: dict[str, list[BusinessEvent]]
tasks_by_claim_id: dict[str, list[ApprovalTask]]
risks_by_claim_id: dict[
str,
list[tuple[RiskObservation, RiskDisposition | None]],
]
class ApprovalTaskBackfillService:
"""显式、可预览、幂等地为历史待审批 Claim 创建根任务。
不自行提交,也绝不能从 GET 隐式调用;``run`` 默认 dry-run。
"""
def __init__(
self,
db: Session,
*,
tenant_id: str,
created_before: datetime | None = None,
sla_hours: int = DEFAULT_APPROVAL_SLA_HOURS,
) -> None:
self.db = db
self.tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
self.created_before = (
self._aware(created_before, field_name="created_before")
if created_before is not None
else None
)
self.sla_hours = int(sla_hours)
if self.sla_hours <= 0:
raise ValueError("sla_hours must be greater than zero")
self.claim_policy = ExpenseClaimAccessPolicy(db)
self.projections = ApprovalTaskProjectionService(db)
self.events = ApprovalTaskEventProtocol(db)
def run(
self,
*,
dry_run: bool = True,
run_id: str | None = None,
batch_size: int = DEFAULT_BACKFILL_BATCH_SIZE,
after: ApprovalTaskBackfillCursor | None = None,
backfilled_at: datetime | None = None,
) -> ApprovalTaskBackfillPreview | ApprovalTaskBackfillResult:
if dry_run:
return self.preview(batch_size=batch_size, after=after)
return self.apply_batch(
run_id=self._require_text(run_id, field_name="run_id", max_length=64),
batch_size=batch_size,
after=after,
backfilled_at=backfilled_at,
)
def preview(
self,
*,
batch_size: int = DEFAULT_BACKFILL_BATCH_SIZE,
after: ApprovalTaskBackfillCursor | None = None,
) -> ApprovalTaskBackfillPreview:
"""只读预览;不 add、不 flush、不 commit。"""
with self.db.no_autoflush:
claims, has_more = self._load_claims(
batch_size=self._batch_size(batch_size),
after=after,
lock_rows=False,
)
context = self._load_context(claims)
items = self._classify(claims, context=context)
return ApprovalTaskBackfillPreview(
tenant_id=self.tenant_id,
inspected=len(items),
eligible=self._count(items, ApprovalTaskBackfillDisposition.ELIGIBLE),
existing=self._count(items, ApprovalTaskBackfillDisposition.EXISTING),
skipped=self._skipped_count(items),
has_more=has_more,
next_cursor=self._next_cursor(claims),
items=items,
)
def apply_batch(
self,
*,
run_id: str,
batch_size: int = DEFAULT_BACKFILL_BATCH_SIZE,
after: ApprovalTaskBackfillCursor | None = None,
backfilled_at: datetime | None = None,
) -> ApprovalTaskBackfillResult:
"""锁定并应用一批,不 commit失败时由调用方回滚整个批次。"""
normalized_run_id = self._require_text(run_id, field_name="run_id", max_length=64)
applied_at = self._aware(
backfilled_at or datetime.now(UTC),
field_name="backfilled_at",
)
claims, has_more = self._load_claims(
batch_size=self._batch_size(batch_size),
after=after,
lock_rows=True,
)
context = self._load_context(claims)
classified = self._classify(claims, context=context)
claims_by_id = {str(claim.id): claim for claim in claims}
result_items: list[ApprovalTaskBackfillItem] = []
for item in classified:
if item.disposition is not ApprovalTaskBackfillDisposition.ELIGIBLE:
result_items.append(item)
continue
claim = claims_by_id[item.claim_id]
assignment = self._resolve_assignment(claim, node_key=item.node_key)
if assignment is None:
result_items.append(
replace(
item,
disposition=ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED,
reason="当前审批节点无法解析到安全的处理人。",
)
)
continue
task = self._create_task(
claim,
item=item,
assignment=assignment,
context=context,
run_id=normalized_run_id,
backfilled_at=applied_at,
)
result_items.append(
replace(
item,
disposition=ApprovalTaskBackfillDisposition.CREATED,
task_id=task.id,
)
)
result = tuple(result_items)
return ApprovalTaskBackfillResult(
tenant_id=self.tenant_id,
run_id=normalized_run_id,
inspected=len(result),
created=self._count(result, ApprovalTaskBackfillDisposition.CREATED),
existing=self._count(result, ApprovalTaskBackfillDisposition.EXISTING),
skipped=self._skipped_count(result),
has_more=has_more,
next_cursor=self._next_cursor(claims),
items=result,
)
def derive_entered_at(
self,
claim: ExpenseClaim,
*,
events: Sequence[BusinessEvent],
approval_stage: str,
) -> tuple[datetime, str, BusinessEvent | None]:
"""按 BusinessEvent > submitted_at > updated_at > created_at 推导节点时间。"""
matching_events = [
event
for event in events
if self._event_enters_stage(event, approval_stage=approval_stage)
]
if matching_events:
event = max(
matching_events,
key=lambda value: self._as_utc(value.occurred_at),
)
return self._as_utc(event.occurred_at), "workflow_event", event
if claim.submitted_at is not None:
return self._as_utc(claim.submitted_at), "submitted_at", None
if claim.updated_at is not None:
return self._as_utc(claim.updated_at), "updated_at", None
if claim.created_at is not None:
return self._as_utc(claim.created_at), "backfill", None
raise ValueError(f"Claim {claim.id} 缺少可用于回填的时间。")
def _load_claims(
self,
*,
batch_size: int,
after: ApprovalTaskBackfillCursor | None,
lock_rows: bool,
) -> tuple[list[ExpenseClaim], bool]:
normalized_status = func.lower(func.trim(func.coalesce(ExpenseClaim.status, "")))
stmt = (
select(ExpenseClaim)
.options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
)
.where(
normalized_status.in_(_PENDING_STATUS_VALUES),
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(self.tenant_id),
)
)
if self.created_before is not None:
stmt = stmt.where(ExpenseClaim.created_at < self.created_before)
if after is not None:
claim_id = self._require_text(
after.claim_id,
field_name="after.claim_id",
max_length=36,
)
stmt = stmt.where(
or_(
ExpenseClaim.created_at > after.created_at,
and_(
ExpenseClaim.created_at == after.created_at,
ExpenseClaim.id > claim_id,
),
)
)
stmt = stmt.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.id.asc()).limit(
batch_size + 1
)
if lock_rows:
stmt = stmt.with_for_update()
rows = list(self.db.scalars(stmt).unique().all())
return rows[:batch_size], len(rows) > batch_size
def _load_context(self, claims: Sequence[ExpenseClaim]) -> _BatchContext:
claim_ids = [str(claim.id) for claim in claims]
if not claim_ids:
return _BatchContext({}, {}, {}, {})
links_by_claim_id = {
str(link.resource_id): link
for link in self.db.scalars(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id.in_(claim_ids),
)
).all()
}
events_by_claim_id: dict[str, list[BusinessEvent]] = {}
for event in self.db.scalars(
select(BusinessEvent)
.where(
BusinessEvent.tenant_id == self.tenant_id,
BusinessEvent.aggregate_type == "expense_claim",
BusinessEvent.aggregate_id.in_(claim_ids),
)
.order_by(BusinessEvent.occurred_at.desc(), BusinessEvent.id.desc())
).all():
events_by_claim_id.setdefault(str(event.aggregate_id), []).append(event)
tasks_by_claim_id: dict[str, list[ApprovalTask]] = {}
for task in self.db.scalars(
select(ApprovalTask).where(
ApprovalTask.tenant_id == self.tenant_id,
ApprovalTask.claim_id.in_(claim_ids),
ApprovalTask.task_kind == "root",
)
).all():
tasks_by_claim_id.setdefault(str(task.claim_id), []).append(task)
risks_by_claim_id: dict[
str,
list[tuple[RiskObservation, RiskDisposition | None]],
] = {}
for observation, disposition in self.db.execute(
select(RiskObservation, RiskDisposition)
.outerjoin(
RiskDisposition,
(RiskDisposition.tenant_id == RiskObservation.tenant_id)
& (RiskDisposition.observation_id == RiskObservation.id),
)
.where(
RiskObservation.tenant_id == self.tenant_id,
RiskObservation.claim_id.in_(claim_ids),
)
).all():
risks_by_claim_id.setdefault(str(observation.claim_id), []).append(
(observation, disposition)
)
return _BatchContext(
links_by_claim_id=links_by_claim_id,
events_by_claim_id=events_by_claim_id,
tasks_by_claim_id=tasks_by_claim_id,
risks_by_claim_id=risks_by_claim_id,
)
def _classify(
self,
claims: Sequence[ExpenseClaim],
*,
context: _BatchContext,
) -> tuple[ApprovalTaskBackfillItem, ...]:
items: list[ApprovalTaskBackfillItem] = []
for claim in claims:
claim_id = str(claim.id)
link = context.links_by_claim_id.get(claim_id)
if link is not None and link.tenant_id != self.tenant_id:
items.append(
self._item(
claim,
ApprovalTaskBackfillDisposition.TENANT_CONFLICT,
reason="费用单据已关联到其他租户。",
)
)
continue
state = self._normalized_state(claim)
node_spec = _NODE_BY_STAGE.get(state.approval_stage)
if state.status != "submitted" or node_spec is None:
items.append(
self._item(
claim,
ApprovalTaskBackfillDisposition.UNSUPPORTED_STAGE,
reason="当前状态或审批阶段不属于可回填的待审批节点。",
)
)
continue
node_key, _label, _sequence = node_spec
entered_at, entered_source, _source_event = self.derive_entered_at(
claim,
events=context.events_by_claim_id.get(claim_id, ()),
approval_stage=state.approval_stage,
)
node_entry_key = self._node_entry_key(
claim_id=claim_id,
node_key=node_key,
entered_at=entered_at,
)
previous_tasks = context.tasks_by_claim_id.get(claim_id, ())
existing = next(
(
task
for task in previous_tasks
if task.status in _OPEN_TASK_STATUSES or task.node_entry_key == node_entry_key
),
None,
)
if existing is not None:
items.append(
self._item(
claim,
ApprovalTaskBackfillDisposition.EXISTING,
node_key=node_key,
entered_at=entered_at,
entered_at_source=entered_source,
assignee_key=existing.assignee_key,
task_id=existing.id,
reason="当前 Claim 已有开放根任务或相同节点进入事实。",
)
)
continue
assignment = self._resolve_assignment(claim, node_key=node_key)
if assignment is None:
items.append(
self._item(
claim,
ApprovalTaskBackfillDisposition.ASSIGNEE_UNRESOLVED,
node_key=node_key,
entered_at=entered_at,
entered_at_source=entered_source,
reason="当前审批节点无法解析到安全的处理人。",
)
)
continue
items.append(
self._item(
claim,
ApprovalTaskBackfillDisposition.ELIGIBLE,
node_key=node_key,
entered_at=entered_at,
entered_at_source=entered_source,
assignee_key=assignment.key,
)
)
return tuple(items)
def _resolve_assignment(
self,
claim: ExpenseClaim,
*,
node_key: str,
) -> _Assignment | None:
employee: Employee | None
if node_key == "direct_manager":
claim_employee = claim.employee
if claim_employee is None:
claim_employee = self.claim_policy.resolve_claim_employee_for_backfill(claim)
employee = claim_employee.manager if claim_employee is not None else None
if employee is None:
manager_name = self.claim_policy.resolve_claim_manager_name(claim)
employee = self.claim_policy.resolve_employee_by_identity_candidates([manager_name])
elif node_key == "budget_manager":
employee = self.claim_policy.resolve_department_budget_manager(claim)
elif node_key == "finance":
employee = self.claim_policy.resolve_finance_approver(claim)
if employee is None:
return _Assignment("role", "finance", None, "财务审批角色")
else:
return None
if employee is None or employee.id == str(claim.employee_id or "").strip():
return None
employment_status = str(employee.employment_status or "").strip().lower()
if employment_status not in _ACTIVE_EMPLOYMENT_STATUSES:
return (
_Assignment("role", "finance", None, "财务审批角色")
if node_key == "finance"
else None
)
return _Assignment(
kind="employee",
key=str(employee.id),
employee_id=str(employee.id),
name=str(employee.name or employee.employee_no or employee.id).strip(),
)
def _create_task(
self,
claim: ExpenseClaim,
*,
item: ApprovalTaskBackfillItem,
assignment: _Assignment,
context: _BatchContext,
run_id: str,
backfilled_at: datetime,
) -> ApprovalTask:
if item.entered_at is None:
raise RuntimeError("eligible backfill item is missing entered_at")
state = self._normalized_state(claim)
node_key, node_label, node_sequence = _NODE_BY_STAGE[state.approval_stage]
link = context.links_by_claim_id.get(str(claim.id))
node_entry_key = self._node_entry_key(
claim_id=str(claim.id), node_key=node_key, entered_at=item.entered_at
)
task = ApprovalTask(
id=str(uuid.uuid4()),
tenant_id=self.tenant_id,
claim_id=str(claim.id),
expense_case_id=link.expense_case_id if link is not None else None,
node_instance_id=node_entry_key,
node_entry_key=node_entry_key,
parent_task_id=None,
task_kind="root",
node_key=node_key,
node_label=node_label,
node_sequence=node_sequence,
sequence_order=0,
coordination_mode="single",
owner_kind=assignment.kind,
owner_key=assignment.key,
owner_employee_id=assignment.employee_id,
owner_name=assignment.name,
assignee_kind=assignment.kind,
assignee_key=assignment.key,
assignee_employee_id=assignment.employee_id,
assignee_name=assignment.name,
status="pending",
decision=None,
version=1,
claim_status_snapshot=state.status,
claim_stage_snapshot=state.approval_stage,
entered_at=item.entered_at,
entered_at_source=item.entered_at_source,
activated_at=item.entered_at,
sla_hours_snapshot=self.sla_hours,
completed_at=None,
cancelled_at=None,
escalation_level=0,
)
projection = self.projections.build(
claim,
tenant_id=self.tenant_id,
entered_at=item.entered_at,
now=backfilled_at,
sla_hours=self.sla_hours,
observation_rows=context.risks_by_claim_id.get(str(claim.id), ()),
)
self.projections.apply(task, projection)
self.db.add(task)
self.db.flush()
source_event = self._source_event(
context.events_by_claim_id.get(str(claim.id), ()),
approval_stage=state.approval_stage,
)
payload = {
"backfill_run_id": run_id,
"history_reconstructed": False,
"current_node_reconstructed": True,
"claim_id": str(claim.id),
"claim_no": str(claim.claim_no or ""),
"approval_stage": state.approval_stage,
"entered_at": self._isoformat(item.entered_at),
"entered_at_source": item.entered_at_source,
}
event, _response = self.events.append_event(
task=task,
event_type="node_entered_backfilled",
actor_id="approval-task-backfill",
actor_name="审批任务历史回填",
actor_type="system",
request_id=self._request_id(run_id=run_id, task=task),
expected_task_version=0,
payload=payload,
before_json={},
business_event_id=source_event.id if source_event is not None else None,
correlation_id=run_id,
occurred_at=backfilled_at,
)
self.db.flush()
return task
@staticmethod
def _item(
claim: ExpenseClaim,
disposition: ApprovalTaskBackfillDisposition,
**values: Any,
) -> ApprovalTaskBackfillItem:
return ApprovalTaskBackfillItem(
claim_id=str(claim.id),
claim_no=str(claim.claim_no or ""),
disposition=disposition,
**values,
)
@staticmethod
def _event_enters_stage(event: BusinessEvent, *, approval_stage: str) -> bool:
payload = event.payload_json if isinstance(event.payload_json, dict) else {}
next_stage = str(
payload.get("next_approval_stage") or payload.get("approval_stage") or ""
).strip()
next_status = str(payload.get("next_status") or "submitted").strip().lower()
return next_stage == approval_stage and next_status in _PENDING_STATUS_VALUES
def _source_event(
self,
events: Sequence[BusinessEvent],
*,
approval_stage: str,
) -> BusinessEvent | None:
matches = [
event
for event in events
if self._event_enters_stage(event, approval_stage=approval_stage)
]
return max(matches, key=lambda value: self._as_utc(value.occurred_at)) if matches else None
@staticmethod
def _normalized_state(claim: ExpenseClaim) -> Any:
raw_status = str(claim.status or "").strip().lower()
status = "submitted" if raw_status in {"in_approval", "under_review"} else claim.status
return normalize_expense_claim_state(
status,
claim.approval_stage,
claim_no=claim.claim_no,
expense_type=claim.expense_type,
)
@staticmethod
def _node_digest(*, claim_id: str, node_key: str, entered_at: datetime) -> str:
raw = f"{claim_id}:{node_key}:{ApprovalTaskBackfillService._isoformat(entered_at)}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:20]
@classmethod
def _node_entry_key(cls, *, claim_id: str, node_key: str, entered_at: datetime) -> str:
digest = cls._node_digest(
claim_id=claim_id,
node_key=node_key,
entered_at=entered_at,
)
return f"claim:{claim_id}:{node_key}:{digest}"
@staticmethod
def _request_id(*, run_id: str, task: ApprovalTask) -> str:
digest = hashlib.sha256(f"{task.tenant_id}:{task.node_entry_key}".encode()).hexdigest()[:24]
return f"backfill:{run_id}:{digest}"[:120]
@staticmethod
def _count(
items: Sequence[ApprovalTaskBackfillItem],
disposition: ApprovalTaskBackfillDisposition,
) -> int:
return sum(1 for item in items if item.disposition is disposition)
@staticmethod
def _skipped_count(items: Sequence[ApprovalTaskBackfillItem]) -> int:
return sum(
1
for item in items
if item.disposition
not in {
ApprovalTaskBackfillDisposition.ELIGIBLE,
ApprovalTaskBackfillDisposition.CREATED,
ApprovalTaskBackfillDisposition.EXISTING,
}
)
@staticmethod
def _next_cursor(
claims: Sequence[ExpenseClaim],
) -> ApprovalTaskBackfillCursor | None:
if not claims:
return None
claim = claims[-1]
return ApprovalTaskBackfillCursor(
created_at=claim.created_at,
claim_id=str(claim.id),
)
@staticmethod
def _batch_size(value: int) -> int:
normalized = int(value)
if normalized < 1 or normalized > MAX_BACKFILL_BATCH_SIZE:
raise ValueError(f"batch_size must be between 1 and {MAX_BACKFILL_BATCH_SIZE}")
return normalized
@staticmethod
def _require_text(value: str | None, *, field_name: str, max_length: int) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{field_name} must not be empty")
if len(normalized) > max_length:
raise ValueError(f"{field_name} must be at most {max_length} characters")
return normalized
@staticmethod
def _aware(value: datetime, *, field_name: str) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{field_name} must include a timezone")
return value.astimezone(UTC)
@staticmethod
def _as_utc(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
@staticmethod
def _isoformat(value: datetime) -> str:
return ApprovalTaskBackfillService._as_utc(value).isoformat().replace("+00:00", "Z")

View File

@@ -0,0 +1,207 @@
from __future__ import annotations
import hashlib
from collections.abc import Callable
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.db.session import get_session_factory
from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask
from app.schemas.approval_task import (
ApprovalTaskBatchApproveCreate,
ApprovalTaskBatchApproveRead,
ApprovalTaskBatchItemRead,
)
from app.schemas.reimbursement import ExpenseClaimRead
from app.services.approval_action_protocol import ApprovalActionConflictError
from app.services.approval_task_access_policy import ApprovalTaskPermissionError
from app.services.approval_task_protocol import ApprovalTaskConflictError
from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claims import ExpenseClaimService
class ApprovalTaskBatchService:
"""小批量审批编排;每一项使用独立数据库事务。"""
def __init__(
self,
session_factory: Callable[[], Session] | None = None,
) -> None:
self.session_factory = session_factory or get_session_factory()
def approve(
self,
payload: ApprovalTaskBatchApproveCreate,
current_user: CurrentUserContext,
) -> ApprovalTaskBatchApproveRead:
results = [
self._approve_one(
item=item,
batch_request_id=payload.batch_request_id,
current_user=current_user,
)
for item in payload.items
]
counts = {
name: sum(item.status == name for item in results)
for name in (
"succeeded",
"replayed",
"conflict",
"blocked",
"forbidden",
"failed",
)
}
success_count = counts["succeeded"] + counts["replayed"]
overall = (
"succeeded"
if success_count == len(results)
else "failed"
if success_count == 0
else "partial"
)
return ApprovalTaskBatchApproveRead(
batch_request_id=payload.batch_request_id,
status=overall,
succeeded_count=counts["succeeded"],
replayed_count=counts["replayed"],
conflict_count=counts["conflict"],
blocked_count=counts["blocked"],
forbidden_count=counts["forbidden"],
failed_count=counts["failed"],
items=results,
)
def _approve_one(
self,
*,
item,
batch_request_id: str,
current_user: CurrentUserContext,
) -> ApprovalTaskBatchItemRead:
request_id = _item_request_id(batch_request_id, item.task_id)
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
db = self.session_factory()
claim_id: str | None = None
try:
task = db.scalar(
select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.id == item.task_id,
)
)
if task is None:
return self._result(
item.task_id,
None,
"conflict",
"TASK_NOT_FOUND",
"任务不存在或已移出当前租户。",
)
claim_id = task.claim_id
if not task.batch_eligible:
reasons = "".join(task.batch_block_reasons_json or [])
return self._result(
item.task_id,
claim_id,
"blocked",
"TASK_NOT_BATCH_ELIGIBLE",
reasons or "该任务不满足安全批量审批条件。",
)
actor_id = str(current_user.username or "").strip().casefold()
replayed = db.scalar(
select(ApprovalActionLedger.id).where(
ApprovalActionLedger.tenant_id == tenant,
ApprovalActionLedger.actor_id == actor_id,
ApprovalActionLedger.request_id == request_id,
ApprovalActionLedger.completed_at.is_not(None),
)
) is not None
claim = ExpenseClaimService(db).approve_claim(
task.claim_id,
current_user,
opinion=item.opinion,
request_id=request_id,
expected_status=item.expected_status,
expected_approval_stage=item.expected_approval_stage,
task_id=task.id,
expected_task_version=item.expected_task_version,
)
if claim is None:
return self._result(
item.task_id,
claim_id,
"conflict",
"CLAIM_NOT_FOUND",
"关联单据不存在或已不可见。",
)
claim_read = (
claim
if isinstance(claim, ExpenseClaimRead)
else ExpenseClaimRead.model_validate(claim)
)
return self._result(
item.task_id,
claim_id,
"replayed" if replayed else "succeeded",
"REPLAYED" if replayed else "APPROVED",
"审批结果已安全重放。" if replayed else "审批通过。",
claim=claim_read.model_dump(mode="json"),
)
except ApprovalTaskPermissionError as error:
return self._result(item.task_id, claim_id, "forbidden", "FORBIDDEN", str(error))
except ExpenseClaimRiskBlockedError as error:
return self._result(item.task_id, claim_id, "blocked", "OPEN_HIGH_RISK", str(error))
except (ApprovalActionConflictError, ApprovalTaskConflictError) as error:
return self._result(item.task_id, claim_id, "conflict", "CONCURRENT_CHANGE", str(error))
except ValueError as error:
return self._result(
item.task_id,
claim_id,
"blocked",
"VALIDATION_BLOCKED",
str(error),
)
except Exception:
return self._result(
item.task_id,
claim_id,
"failed",
"INTERNAL_ERROR",
"该任务处理失败,请稍后单独重试。",
)
finally:
db.rollback()
db.close()
@staticmethod
def _result(
task_id: str,
claim_id: str | None,
status: str,
code: str,
message: str,
*,
claim: dict | None = None,
) -> ApprovalTaskBatchItemRead:
return ApprovalTaskBatchItemRead(
task_id=task_id,
claim_id=claim_id,
status=status,
code=code,
message=message,
claim=claim,
)
def _item_request_id(batch_request_id: str, task_id: str) -> str:
value = f"batch:{batch_request_id}:{task_id}"
if len(value) <= 120:
return value
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()
return f"batch:{digest}"

View File

@@ -0,0 +1,499 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.schemas.approval_task import ApprovalTaskMutationRead
from app.services.approval_task_access_policy import ApprovalTaskAccessPolicy
from app.services.approval_task_protocol import ApprovalTaskEventProtocol, task_state
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claim_workflow_constants import (
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
OPEN_TASK_STATUSES = ("waiting", "pending")
class ApprovalTaskConfigurationError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ApprovalNode:
key: str
label: str
sla_hours: int
@dataclass(frozen=True, slots=True)
class ApprovalAssignment:
kind: str
key: str
employee_id: str | None
name: str
@dataclass(slots=True)
class ApprovalTaskDecisionContext:
task: ApprovalTask
expected_task_version: int
before_json: dict[str, object]
_NODES = {
DIRECT_MANAGER_APPROVAL_STAGE: ApprovalNode(
key="direct_manager",
label=DIRECT_MANAGER_APPROVAL_STAGE,
sla_hours=24,
),
BUDGET_MANAGER_APPROVAL_STAGE: ApprovalNode(
key="budget_manager",
label=BUDGET_MANAGER_APPROVAL_STAGE,
sla_hours=12,
),
FINANCE_APPROVAL_STAGE: ApprovalNode(
key="finance",
label=FINANCE_APPROVAL_STAGE,
sla_hours=8,
),
}
def approval_node_for_stage(stage: object) -> ApprovalNode | None:
return _NODES.get(str(stage or "").strip())
class ApprovalTaskLifecycleService:
"""把既有费用状态机投影成可分配、可审计的个人审批任务。"""
def __init__(self, db: Session) -> None:
self.db = db
self.access = ApprovalTaskAccessPolicy(db)
self.events = ApprovalTaskEventProtocol(db)
def ensure_root_task(
self,
claim: ExpenseClaim,
*,
tenant_id: str,
entered_at: datetime,
entered_at_source: str,
business_event: BusinessEvent | None = None,
actor_id: str = "system",
actor_name: str = "系统",
request_id: str | None = None,
) -> ApprovalTask | None:
node = approval_node_for_stage(claim.approval_stage)
if str(claim.status or "").strip().lower() != "submitted" or node is None:
return None
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
existing = self.db.scalar(
select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.claim_id == claim.id,
ApprovalTask.task_kind == "root",
ApprovalTask.status.in_(OPEN_TASK_STATUSES),
)
)
if existing is not None:
if (
existing.node_key == node.key
and existing.claim_stage_snapshot == str(claim.approval_stage or "").strip()
):
return existing
self._supersede_stale_root(existing, claim=claim)
assignment = self.resolve_assignment(claim, node=node)
sequence = int(
self.db.scalar(
select(func.max(ApprovalTask.node_sequence)).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.claim_id == claim.id,
ApprovalTask.task_kind == "root",
)
)
or 0
) + 1
now = datetime.now(UTC)
normalized_entered_at = _aware_utc(entered_at)
task_id = str(uuid.uuid4())
entry_seed = (
business_event.id
if business_event is not None
else f"{normalized_entered_at.isoformat()}:{uuid.uuid4().hex[:12]}"
)
task = ApprovalTask(
id=task_id,
tenant_id=tenant,
claim_id=claim.id,
expense_case_id=self._expense_case_id(claim, business_event=business_event),
node_instance_id=f"{claim.id}:{sequence}:{node.key}:{uuid.uuid4().hex[:12]}",
node_entry_key=f"{claim.id}:{sequence}:{node.key}:{entry_seed}",
parent_task_id=None,
task_kind="root",
node_key=node.key,
node_label=node.label,
node_sequence=sequence,
sequence_order=0,
coordination_mode="single",
owner_kind=assignment.kind,
owner_key=assignment.key,
owner_employee_id=assignment.employee_id,
owner_name=assignment.name,
assignee_kind=assignment.kind,
assignee_key=assignment.key,
assignee_employee_id=assignment.employee_id,
assignee_name=assignment.name,
delegated_by=None,
delegation_expires_at=None,
status="pending",
decision=None,
opinion=None,
version=1,
claim_status_snapshot=str(claim.status or "").strip().lower(),
claim_stage_snapshot=str(claim.approval_stage or "").strip(),
entered_at=normalized_entered_at,
entered_at_source=entered_at_source,
activated_at=normalized_entered_at,
sla_hours_snapshot=node.sla_hours,
due_at=normalized_entered_at + timedelta(hours=node.sla_hours),
completed_at=None,
cancelled_at=None,
escalation_level=0,
escalated_at=None,
next_escalation_at=normalized_entered_at + timedelta(hours=node.sla_hours),
priority_score=0,
priority_tier="normal",
priority_reasons_json=[],
risk_level="low",
open_risk_count=0,
evidence_completeness=0,
batch_eligible=False,
batch_block_reasons_json=[],
projection_updated_at=now,
created_at=now,
updated_at=now,
)
self._apply_projection(task, claim=claim, now=now)
self.db.add(task)
self.db.flush()
event_request_id = str(request_id or "").strip() or f"node-enter:{task.id}"
self.events.append_event(
task=task,
event_type="node_entered",
actor_id=actor_id,
actor_name=actor_name,
actor_type="system" if actor_id == "system" else "user",
request_id=event_request_id,
expected_task_version=0,
payload={
"claim_id": claim.id,
"node_key": node.key,
"node_label": node.label,
"entered_at_source": entered_at_source,
},
before_json={},
business_event_id=business_event.id if business_event is not None else None,
correlation_id=(
business_event.correlation_id if business_event is not None else None
),
occurred_at=now,
)
return task
def begin_claim_decision(
self,
claim: ExpenseClaim,
current_user: CurrentUserContext,
*,
action: str,
task_id: str | None,
expected_task_version: int | None,
) -> ApprovalTaskDecisionContext:
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
statement = select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.claim_id == claim.id,
ApprovalTask.status.in_(OPEN_TASK_STATUSES),
)
if task_id:
statement = statement.where(ApprovalTask.id == str(task_id).strip())
else:
statement = statement.where(ApprovalTask.task_kind == "root")
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
statement = statement.with_for_update()
task = self.db.scalar(statement.execution_options(populate_existing=True))
if task is None and not task_id:
task = self.ensure_root_task(
claim,
tenant_id=tenant,
entered_at=claim.submitted_at or claim.updated_at or datetime.now(UTC),
entered_at_source=("submitted_at" if claim.submitted_at else "backfill"),
actor_id="system",
actor_name="系统兼容桥",
)
if task is None:
raise ApprovalTaskConflictError("当前单据没有可执行的审批任务。")
if task.task_kind != "root" and action != "return":
raise ApprovalTaskConflictError("加签或会签通过不会直接推进单据节点。")
if task.status != "pending":
raise ApprovalTaskConflictError("当前审批任务正在等待加签或会签完成。")
if (
task.claim_status_snapshot != str(claim.status or "").strip().lower()
or task.claim_stage_snapshot != str(claim.approval_stage or "").strip()
):
raise ApprovalTaskConflictError("审批任务与单据节点不一致,请刷新任务队列。")
expected = int(expected_task_version or task.version)
if task.version != expected:
from app.services.approval_task_protocol import (
ApprovalTaskVersionConflictError,
)
raise ApprovalTaskVersionConflictError(task.version)
self.access.require_action(task, current_user, action=action, claim=claim)
return ApprovalTaskDecisionContext(
task=task,
expected_task_version=expected,
before_json=task_state(task),
)
def complete_claim_decision(
self,
context: ApprovalTaskDecisionContext,
claim: ExpenseClaim,
current_user: CurrentUserContext,
*,
action: str,
opinion: str,
request_id: str,
ledger: ApprovalActionLedger,
business_event: BusinessEvent | None = None,
) -> ApprovalTaskMutationRead:
task = context.task
now = datetime.now(UTC)
task.status = "completed" if action == "approve" else "returned"
task.decision = "approved" if action == "approve" else "returned"
task.opinion = str(opinion or "").strip() or None
task.version += 1
task.claim_status_snapshot = str(claim.status or "").strip().lower()
task.claim_stage_snapshot = str(claim.approval_stage or "").strip()
task.completed_at = now
task.updated_at = now
related_tasks: list[ApprovalTask] = []
if action == "return" and task.task_kind != "root":
related_tasks = self._cancel_node_family(task, claim=claim, now=now)
# Session 关闭了 autoflush先持久化当前根任务的终态后续创建下一
# 节点时才不会把同一对象误判成仍开放并改写为 superseded。
self.db.flush()
next_task = self.ensure_root_task(
claim,
tenant_id=task.tenant_id,
entered_at=(
business_event.occurred_at
if business_event is not None
else now
),
entered_at_source=("workflow_event" if business_event is not None else "backfill"),
business_event=business_event,
actor_id="system",
actor_name="审批任务引擎",
)
if next_task is not None:
related_tasks.append(next_task)
_, response = self.events.append_event(
task=task,
event_type=("task_approved" if action == "approve" else "task_returned"),
actor_id=self.access.actor_id(current_user),
actor_name=str(current_user.name or current_user.username),
actor_type="user",
request_id=request_id,
expected_task_version=context.expected_task_version,
payload={"opinion": str(opinion or "").strip()},
before_json=context.before_json,
related_tasks=related_tasks,
approval_action_ledger_id=ledger.id,
business_event_id=business_event.id if business_event is not None else None,
correlation_id=(
business_event.correlation_id if business_event is not None else None
),
occurred_at=now,
)
return response
def _cancel_node_family(
self,
task: ApprovalTask,
*,
claim: ExpenseClaim,
now: datetime,
) -> list[ApprovalTask]:
statement = (
select(ApprovalTask)
.where(
ApprovalTask.tenant_id == task.tenant_id,
ApprovalTask.node_instance_id == task.node_instance_id,
ApprovalTask.id != task.id,
ApprovalTask.status.in_(OPEN_TASK_STATUSES),
)
.order_by(ApprovalTask.sequence_order.asc())
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
statement = statement.with_for_update()
related = list(self.db.scalars(statement).all())
for item in related:
before = task_state(item)
expected = item.version
item.status = "cancelled"
item.decision = None
item.completed_at = None
item.cancelled_at = now
item.version += 1
item.claim_status_snapshot = str(claim.status or "").strip().lower()
item.claim_stage_snapshot = str(claim.approval_stage or "").strip()
item.updated_at = now
self.events.append_event(
task=item,
event_type="task_cancelled",
actor_id="system",
actor_name="审批任务引擎",
actor_type="system",
request_id=f"cancel:{item.id}:{item.version}",
expected_task_version=expected,
payload={"reason": "participant_returned_claim"},
before_json=before,
occurred_at=now,
)
return related
def resolve_assignment(
self,
claim: ExpenseClaim,
*,
node: ApprovalNode,
) -> ApprovalAssignment:
if node.key == "direct_manager":
employee = self.access.claim_policy.resolve_claim_employee_for_backfill(claim)
manager = employee.manager if employee is not None else None
if manager is None:
manager = self.access.claim_policy.resolve_employee_by_identity_candidates(
[str(claim.manager_name or "").strip()]
)
if manager is None:
raise ApprovalTaskConfigurationError(
"未配置可唯一识别的直属领导,无法创建审批任务。"
)
return self._employee_assignment(manager)
if node.key == "budget_manager":
employee = self.access.claim_policy.resolve_department_budget_manager(claim)
if employee is None:
raise ApprovalTaskConfigurationError(
"未配置同部门 P8 预算管理者,无法创建审批任务。"
)
return self._employee_assignment(employee)
finance = self.access.claim_policy.resolve_finance_approver(claim)
if finance is not None:
return self._employee_assignment(finance)
return ApprovalAssignment(
kind="role",
key="finance",
employee_id=None,
name="财务审批组",
)
def _supersede_stale_root(
self,
task: ApprovalTask,
*,
claim: ExpenseClaim,
) -> None:
before = task_state(task)
now = datetime.now(UTC)
expected = task.version
task.status = "superseded"
task.decision = None
task.completed_at = None
task.cancelled_at = now
task.version += 1
task.claim_status_snapshot = str(claim.status or "").strip().lower()
task.claim_stage_snapshot = str(claim.approval_stage or "").strip()
task.updated_at = now
self.events.append_event(
task=task,
event_type="task_superseded",
actor_id="system",
actor_name="审批任务引擎",
actor_type="system",
request_id=f"supersede:{task.id}:{task.version}",
expected_task_version=expected,
payload={"reason": "claim_stage_changed"},
before_json=before,
occurred_at=now,
)
def _expense_case_id(
self,
claim: ExpenseClaim,
*,
business_event: BusinessEvent | None,
) -> str | None:
if business_event is not None:
return business_event.expense_case_id
return self.db.scalar(
select(ExpenseCaseLink.expense_case_id).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == claim.id,
)
)
@staticmethod
def _employee_assignment(employee: Employee) -> ApprovalAssignment:
key = str(employee.id or employee.email or employee.employee_no).strip()
if not key:
raise ApprovalTaskConfigurationError("审批人缺少稳定身份标识。")
return ApprovalAssignment(
kind="employee",
key=key,
employee_id=employee.id,
name=str(employee.name or employee.email or employee.employee_no).strip(),
)
@staticmethod
def _read_without_permission(task: ApprovalTask):
from app.services.approval_task_protocol import task_read
return task_read(task)
@staticmethod
def _apply_projection(
task: ApprovalTask,
*,
claim: ExpenseClaim,
now: datetime,
) -> None:
from app.services.approval_task_projection import ApprovalTaskProjectionService
ApprovalTaskProjectionService.apply(task, claim=claim, now=now)
def _aware_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
# Local alias keeps callers independent of protocol module layout.
from app.services.approval_task_protocol import ApprovalTaskConflictError # noqa: E402

View File

@@ -0,0 +1,566 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session, object_session
from app.models.approval_task import ApprovalTask
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.services.expense_claim_risk_flags import (
claim_risk_flag_observation_key,
claim_risk_flag_severity,
is_open_claim_risk_flag,
)
from app.services.expense_claim_risk_stage import (
risk_business_stage_for_claim,
risk_flag_business_stage,
)
from app.services.expense_claim_status_registry import (
is_application_claim_reference,
normalize_expense_claim_state,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.expense_claim_workflow_constants import (
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
DEFAULT_APPROVAL_SLA_HOURS = 24
SUPPORTED_APPROVAL_STAGES = frozenset(
{
DIRECT_MANAGER_APPROVAL_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
}
)
_RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
_RISK_WEIGHTS = {"low": 0, "medium": 14, "high": 30, "critical": 38}
_CLOSED_OBSERVATION_STATES = {
"false_positive",
"ignored",
"resolved",
}
_BATCH_AMOUNT_LIMIT = Decimal("10000")
_BATCH_BUDGET_USAGE_LIMIT = Decimal("90")
_COMPLETENESS_QUANTUM = Decimal("0.0001")
@dataclass(frozen=True, slots=True)
class ApprovalTaskProjection:
"""审批队列的可重算快照;它不是审批放行的权威依据。"""
priority_score: int
priority_tier: str
priority_reasons_json: tuple[dict[str, Any], ...]
risk_level: str
open_risk_count: int
evidence_completeness: Decimal
batch_eligible: bool
batch_block_reasons_json: tuple[str, ...]
due_at: datetime
next_escalation_at: datetime
projection_updated_at: datetime
class ApprovalTaskProjectionService:
"""计算审批任务的排序、风险、材料和安全批处理投影。
投影仅用于列表排序与操作提示。真正审批时仍必须重新读取 Claim、风险处置、
预算和任务版本,不能用这里的 ``batch_eligible`` 替代权威风控校验。
"""
def __init__(self, db: Session) -> None:
self.db = db
def build(
self,
claim: ExpenseClaim,
*,
tenant_id: str,
entered_at: datetime,
now: datetime | None = None,
sla_hours: int = DEFAULT_APPROVAL_SLA_HOURS,
observation_rows: Sequence[tuple[RiskObservation, RiskDisposition | None]] | None = None,
) -> ApprovalTaskProjection:
normalized_tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
normalized_entered_at = self._aware(entered_at, field_name="entered_at")
generated_at = self._aware(now or datetime.now(UTC), field_name="now")
normalized_sla_hours = int(sla_hours)
if normalized_sla_hours <= 0:
raise ValueError("sla_hours must be greater than zero")
rows = (
list(observation_rows)
if observation_rows is not None
else self._load_observation_rows(
tenant_id=normalized_tenant,
claim_id=str(claim.id or "").strip(),
)
)
risk_level, open_risk_count = self._combined_risk_summary(
claim,
rows,
now=generated_at,
)
evidence_completeness, missing_evidence = self._evidence_summary(claim)
budget_usage_rate = self._budget_usage_rate(claim.risk_flags_json)
amount = self._decimal(claim.amount)
due_at = normalized_entered_at + timedelta(hours=normalized_sla_hours)
waiting_hours = max(
Decimal("0"),
Decimal(str((generated_at - normalized_entered_at).total_seconds())) / Decimal("3600"),
)
reasons = self._priority_reasons(
risk_level=risk_level,
open_risk_count=open_risk_count,
waiting_hours=waiting_hours,
sla_hours=normalized_sla_hours,
budget_usage_rate=budget_usage_rate,
amount=amount,
missing_evidence=missing_evidence,
)
score = min(100, sum(int(item.get("weight") or 0) for item in reasons))
priority_tier = "urgent" if score >= 65 else "high" if score >= 40 else "normal"
state = normalize_expense_claim_state(
claim.status,
claim.approval_stage,
claim_no=claim.claim_no,
expense_type=claim.expense_type,
)
batch_block_reasons = self._batch_block_reasons(
approval_stage=state.approval_stage,
risk_level=risk_level,
open_risk_count=open_risk_count,
evidence_completeness=evidence_completeness,
amount=amount,
budget_usage_rate=budget_usage_rate,
overdue=generated_at >= due_at,
)
return ApprovalTaskProjection(
priority_score=score,
priority_tier=priority_tier,
priority_reasons_json=tuple(reasons),
risk_level=risk_level,
open_risk_count=open_risk_count,
evidence_completeness=evidence_completeness,
batch_eligible=not batch_block_reasons,
batch_block_reasons_json=tuple(batch_block_reasons),
due_at=due_at,
next_escalation_at=due_at,
projection_updated_at=generated_at,
)
@staticmethod
def apply(
task: ApprovalTask,
projection: ApprovalTaskProjection | None = None,
*,
claim: ExpenseClaim | None = None,
now: datetime | None = None,
observation_rows: Sequence[tuple[RiskObservation, RiskDisposition | None]] | None = None,
) -> ApprovalTask:
"""计算(可选)并应用投影,不 flush、不 commit。
``claim=`` 便捷形式会从持久化 Claim 取得 Session因此即使 Task 尚未 add
仍能租户隔离地合并结构化风险;也保留显式 ``projection`` 形式供批处理复用。
"""
if projection is None:
if claim is None:
raise ValueError("claim is required when projection is not provided")
db = object_session(claim) or object_session(task)
if db is None and observation_rows is None:
raise ValueError(
"claim must be attached to a Session when observation_rows is omitted"
)
projection = ApprovalTaskProjectionService(db).build( # type: ignore[arg-type]
claim,
tenant_id=str(task.tenant_id or "").strip(),
entered_at=task.entered_at,
now=now,
sla_hours=task.sla_hours_snapshot,
observation_rows=observation_rows,
)
task.priority_score = projection.priority_score
task.priority_tier = projection.priority_tier
task.priority_reasons_json = [dict(item) for item in projection.priority_reasons_json]
task.risk_level = projection.risk_level
task.open_risk_count = projection.open_risk_count
task.evidence_completeness = projection.evidence_completeness
task.batch_eligible = projection.batch_eligible
task.batch_block_reasons_json = list(projection.batch_block_reasons_json)
task.due_at = projection.due_at
task.next_escalation_at = projection.next_escalation_at
task.projection_updated_at = projection.projection_updated_at
return task
def _load_observation_rows(
self,
*,
tenant_id: str,
claim_id: str,
) -> list[tuple[RiskObservation, RiskDisposition | None]]:
if not claim_id:
return []
return list(
self.db.execute(
select(RiskObservation, RiskDisposition)
.outerjoin(
RiskDisposition,
(RiskDisposition.tenant_id == RiskObservation.tenant_id)
& (RiskDisposition.observation_id == RiskObservation.id),
)
.where(
RiskObservation.tenant_id == tenant_id,
RiskObservation.claim_id == claim_id,
)
).all()
)
@classmethod
def _combined_risk_summary(
cls,
claim: ExpenseClaim,
rows: Sequence[tuple[RiskObservation, RiskDisposition | None]],
*,
now: datetime,
) -> tuple[str, int]:
claim_business_stage = risk_business_stage_for_claim(
is_application_claim=is_application_claim_reference(
claim_no=claim.claim_no,
expense_type=claim.expense_type,
)
)
persisted_level, persisted_count = cls._persisted_risk_summary(
rows,
claim_business_stage=claim_business_stage,
now=now,
)
materialized_keys = {
str(observation.observation_key or "").strip()
for observation, _disposition in rows
if str(observation.observation_key or "").strip()
}
raw_level, raw_count = cls._raw_risk_summary(
claim.risk_flags_json,
claim_id=str(claim.id or "").strip(),
materialized_keys=materialized_keys,
claim_business_stage=claim_business_stage,
)
return (
max((persisted_level, raw_level), key=lambda value: _RISK_ORDER[value]),
persisted_count + raw_count,
)
@staticmethod
def _persisted_risk_summary(
rows: Sequence[tuple[RiskObservation, RiskDisposition | None]],
*,
claim_business_stage: str,
now: datetime,
) -> tuple[str, int]:
level = "low"
count = 0
for observation, disposition in rows:
observation_stage = risk_flag_business_stage(
{"control_stage": observation.control_stage},
default="",
)
if observation_stage and observation_stage != claim_business_stage:
continue
adjudication = (
str(
disposition.adjudication
if disposition is not None
else observation.feedback_status or ""
)
.strip()
.lower()
)
lifecycle = (
str(disposition.lifecycle_status if disposition is not None else "open")
.strip()
.lower()
)
observation_status = str(observation.status or "").strip().lower()
active_waiver = False
if disposition is not None and lifecycle == "waived":
expires_at = disposition.waiver_expires_at
normalized_expiry = (
expires_at.replace(tzinfo=UTC)
if expires_at is not None and expires_at.tzinfo is None
else expires_at.astimezone(UTC)
if expires_at is not None
else None
)
active_waiver = (
str(disposition.waiver_decision or "").strip().lower() == "approved"
and normalized_expiry is not None
and normalized_expiry > now
)
if (
adjudication == "false_positive"
or lifecycle == "resolved"
or active_waiver
or observation_status in _CLOSED_OBSERVATION_STATES
):
continue
candidate = str(observation.risk_level or "medium").strip().lower()
if candidate == "danger":
candidate = "high"
if candidate not in _RISK_ORDER:
candidate = "medium"
count += 1
if _RISK_ORDER[candidate] > _RISK_ORDER[level]:
level = candidate
return level, count
@staticmethod
def _raw_risk_summary(
raw_flags: Any,
*,
claim_id: str,
materialized_keys: set[str],
claim_business_stage: str,
) -> tuple[str, int]:
flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
level = "low"
count = 0
for flag in flags:
if not isinstance(flag, dict) or not is_open_claim_risk_flag(flag):
continue
flag_stage = risk_flag_business_stage(flag, default="")
if flag_stage and flag_stage != claim_business_stage:
continue
observation_key = claim_risk_flag_observation_key(flag, claim_id=claim_id)
if observation_key and observation_key in materialized_keys:
continue
candidate = claim_risk_flag_severity(flag) or "medium"
if candidate not in _RISK_ORDER:
candidate = "medium"
count += 1
if _RISK_ORDER[candidate] > _RISK_ORDER[level]:
level = candidate
return level, count
@classmethod
def _evidence_summary(cls, claim: ExpenseClaim) -> tuple[Decimal, tuple[str, ...]]:
checks = [
("事由", bool(str(claim.reason or "").strip())),
("地点", bool(str(claim.location or "").strip())),
("费用明细", bool(list(claim.items or []))),
]
if not is_application_claim_reference(
claim_no=claim.claim_no,
expense_type=claim.expense_type,
):
checks.append(
(
"票据",
int(claim.invoice_count or 0) > 0
or any(str(item.invoice_id or "").strip() for item in list(claim.items or [])),
)
)
missing = tuple(label for label, present in checks if not present)
if not checks:
return Decimal("1.0000"), missing
present_count = len(checks) - len(missing)
completeness = (Decimal(present_count) / Decimal(len(checks))).quantize(
_COMPLETENESS_QUANTUM, rounding=ROUND_HALF_UP
)
return completeness, missing
@classmethod
def _priority_reasons(
cls,
*,
risk_level: str,
open_risk_count: int,
waiting_hours: Decimal,
sla_hours: int,
budget_usage_rate: Decimal | None,
amount: Decimal,
missing_evidence: tuple[str, ...],
) -> list[dict[str, Any]]:
reasons: list[dict[str, Any]] = []
risk_weight = _RISK_WEIGHTS[risk_level]
if risk_weight:
reasons.append(
{
"code": "open_risk",
"label": f"{risk_level.upper()} 风险 {open_risk_count} 项待复核",
"weight": risk_weight,
"tone": "danger" if risk_level in {"high", "critical"} else "warning",
}
)
sla = Decimal(sla_hours)
if waiting_hours >= sla:
reasons.append(
{
"code": "sla_overdue",
"label": f"已超过 {sla_hours} 小时审批 SLA",
"weight": 26,
"tone": "danger",
}
)
elif waiting_hours >= sla * Decimal("0.6667"):
reasons.append(
{
"code": "sla_near_due",
"label": "审批 SLA 即将到期",
"weight": 16,
"tone": "warning",
}
)
elif waiting_hours >= sla * Decimal("0.3333"):
reasons.append(
{
"code": "waiting",
"label": "审批等待时间较长",
"weight": 8,
"tone": "warning",
}
)
if budget_usage_rate is not None and budget_usage_rate >= _BATCH_BUDGET_USAGE_LIMIT:
reasons.append(
{
"code": "budget_pressure",
"label": f"审批后预算占用约 {budget_usage_rate.quantize(Decimal('1'))}%",
"weight": 18,
"tone": "danger" if budget_usage_rate >= 100 else "warning",
}
)
if amount >= Decimal("50000"):
reasons.append(
{
"code": "large_amount",
"label": "大额费用需重点核对",
"weight": 15,
"tone": "warning",
}
)
elif amount >= _BATCH_AMOUNT_LIMIT:
reasons.append(
{
"code": "amount_attention",
"label": "金额达到 1 万元复核线",
"weight": 8,
"tone": "warning",
}
)
if missing_evidence:
reasons.append(
{
"code": "evidence_gap",
"label": "材料仍有缺口:" + "".join(missing_evidence),
"weight": 12,
"tone": "warning",
}
)
if not reasons:
reasons.append(
{
"code": "routine",
"label": "常规低风险待办",
"weight": 0,
"tone": "neutral",
}
)
return reasons
@staticmethod
def _batch_block_reasons(
*,
approval_stage: str,
risk_level: str,
open_risk_count: int,
evidence_completeness: Decimal,
amount: Decimal,
budget_usage_rate: Decimal | None,
overdue: bool,
) -> list[str]:
reasons: list[str] = []
if approval_stage not in SUPPORTED_APPROVAL_STAGES:
reasons.append("unsupported_approval_stage")
if open_risk_count > 0 or risk_level != "low":
reasons.append("open_risk")
if evidence_completeness < Decimal("1"):
reasons.append("evidence_incomplete")
if amount >= _BATCH_AMOUNT_LIMIT:
reasons.append("amount_requires_individual_review")
if budget_usage_rate is not None and budget_usage_rate >= _BATCH_BUDGET_USAGE_LIMIT:
reasons.append("budget_pressure")
if overdue:
reasons.append("sla_overdue")
return reasons
@classmethod
def _budget_usage_rate(cls, raw_flags: Any) -> Decimal | None:
values: list[Decimal] = []
flags = raw_flags if isinstance(raw_flags, list) else [raw_flags]
for flag in flags:
if not isinstance(flag, dict):
continue
candidates = [
flag,
flag.get("metrics"),
flag.get("budget_result"),
(flag.get("route_decision") or {}).get("budget_result")
if isinstance(flag.get("route_decision"), dict)
else None,
]
containers = [item for item in candidates if isinstance(item, dict)]
containers.extend(
item["metrics"]
for item in list(containers)
if isinstance(item.get("metrics"), dict)
)
for container in containers:
for key in (
"after_usage_rate",
"budget_usage_rate",
"usage_rate",
"utilization_rate",
):
value = cls._optional_decimal(container.get(key))
if value is None or value < 0:
continue
values.append(value * 100 if Decimal("0") < value <= 1 else value)
return max(values).quantize(Decimal("0.01")) if values else None
@staticmethod
def _decimal(value: Any) -> Decimal:
try:
return Decimal(str(value or "0"))
except (InvalidOperation, TypeError, ValueError):
return Decimal("0")
@staticmethod
def _optional_decimal(value: Any) -> Decimal | None:
if value is None or str(value).strip() == "":
return None
try:
return Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return None
@staticmethod
def _aware(value: datetime, *, field_name: str) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{field_name} must include a timezone")
return value.astimezone(UTC)

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.approval_task import ApprovalTask
from app.models.financial_record import ExpenseClaim
from app.services.approval_task_projection import ApprovalTaskProjectionService
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
OPEN_TASK_STATUSES = ("waiting", "pending")
class ApprovalTaskProjectionRefreshService:
"""在风险事实变化后,同事务刷新开放任务的只读队列投影。"""
def __init__(self, db: Session) -> None:
self.db = db
self.projections = ApprovalTaskProjectionService(db)
def refresh_claim(
self,
*,
tenant_id: str,
claim_id: str,
now: datetime | None = None,
) -> list[str]:
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(tenant_id)
normalized_claim_id = str(claim_id or "").strip()
if not normalized_claim_id:
return []
claim_statement = select(ExpenseClaim).where(
ExpenseClaim.id == normalized_claim_id,
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant),
)
task_statement = (
select(ApprovalTask)
.where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.claim_id == normalized_claim_id,
ApprovalTask.status.in_(OPEN_TASK_STATUSES),
)
.order_by(ApprovalTask.id.asc())
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
claim_statement = claim_statement.with_for_update()
task_statement = task_statement.with_for_update()
claim = self.db.scalar(claim_statement.execution_options(populate_existing=True))
if claim is None:
return []
generated_at = _as_utc(now or datetime.now(UTC))
tasks = list(
self.db.scalars(
task_statement.execution_options(populate_existing=True)
).all()
)
for task in tasks:
projection = self.projections.build(
claim,
tenant_id=tenant,
entered_at=_as_utc(task.entered_at),
now=generated_at,
sla_hours=task.sla_hours_snapshot,
)
escalation_reasons = [
dict(reason)
for reason in list(task.priority_reasons_json or [])
if str(reason.get("code") or "").startswith("sla_escalated_l")
]
reasons = [
dict(reason)
for reason in projection.priority_reasons_json
if not (
str(reason.get("code") or "") == "routine" and escalation_reasons
)
]
reasons.extend(escalation_reasons)
task.priority_reasons_json = _unique_reasons(reasons)
task.priority_score = min(
100,
sum(int(reason.get("weight") or 0) for reason in task.priority_reasons_json),
)
task.priority_tier = (
"urgent"
if task.priority_score >= 65
else "high"
if task.priority_score >= 40
else "normal"
)
task.risk_level = projection.risk_level
task.open_risk_count = projection.open_risk_count
task.evidence_completeness = projection.evidence_completeness
task.batch_eligible = projection.batch_eligible
task.batch_block_reasons_json = list(projection.batch_block_reasons_json)
task.projection_updated_at = generated_at
task.updated_at = generated_at
if tasks:
self.db.flush()
return [task.id for task in tasks]
def _unique_reasons(reasons: list[dict]) -> list[dict]:
unique: dict[str, dict] = {}
for index, reason in enumerate(reasons):
code = str(reason.get("code") or "reason").strip()
unique[f"{code}:{index}" if code in unique else code] = reason
return list(unique.values())
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -0,0 +1,223 @@
from __future__ import annotations
import hashlib
import json
import uuid
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
from typing import Any
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.schemas.approval_task import (
ApprovalTaskEventRead,
ApprovalTaskMutationRead,
ApprovalTaskRead,
)
from app.services.approval_task_access_policy import ApprovalTaskPermission
class ApprovalTaskConflictError(ValueError):
pass
class ApprovalTaskVersionConflictError(ApprovalTaskConflictError):
def __init__(self, current_version: int) -> None:
self.current_version = current_version
super().__init__(f"审批任务版本已变化,当前版本为 {current_version},请刷新后重试。")
class ApprovalTaskIdempotencyConflictError(ApprovalTaskConflictError):
pass
def normalize_actor_id(value: object) -> str:
return str(value or "").strip().casefold() or "anonymous"
def payload_fingerprint(
*,
task_id: str,
actor_id: str,
event_type: str,
payload: Mapping[str, Any],
) -> str:
canonical = json.dumps(
{
"task_id": str(task_id),
"actor_id": normalize_actor_id(actor_id),
"event_type": str(event_type).strip(),
"payload": dict(payload),
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=_json_default,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
class ApprovalTaskEventProtocol:
"""审批任务 append-only 事件与不可变响应重放。"""
def __init__(self, db: Session) -> None:
self.db = db
def find_replay(
self,
*,
tenant_id: str,
actor_id: str,
request_id: str,
fingerprint: str,
) -> ApprovalTaskMutationRead | None:
event = self.db.scalar(
select(ApprovalTaskEvent).where(
ApprovalTaskEvent.tenant_id == tenant_id,
ApprovalTaskEvent.actor_id == normalize_actor_id(actor_id),
ApprovalTaskEvent.request_id == str(request_id).strip(),
)
)
if event is None:
return None
if str(event.payload_fingerprint) != fingerprint:
raise ApprovalTaskIdempotencyConflictError(
"request_id 已被不同的审批任务动作使用。"
)
response = self._response_from_event(event)
return response.model_copy(update={"replayed": True})
def append_event(
self,
*,
task: ApprovalTask,
event_type: str,
actor_id: str,
actor_name: str,
actor_type: str,
request_id: str,
expected_task_version: int,
payload: Mapping[str, Any],
before_json: Mapping[str, Any],
related_tasks: Sequence[ApprovalTask] = (),
permission: ApprovalTaskPermission | None = None,
approval_action_ledger_id: str | None = None,
business_event_id: str | None = None,
correlation_id: str | None = None,
causation_id: str | None = None,
occurred_at: datetime | None = None,
) -> tuple[ApprovalTaskEvent, ApprovalTaskMutationRead]:
normalized_actor = normalize_actor_id(actor_id)
normalized_request = str(request_id or "").strip()
if not normalized_request:
raise ValueError("审批任务事件 request_id 不能为空。")
fingerprint = payload_fingerprint(
task_id=task.id,
actor_id=normalized_actor,
event_type=event_type,
payload=payload,
)
event = ApprovalTaskEvent(
id=str(uuid.uuid4()),
tenant_id=task.tenant_id,
task_id=task.id,
node_instance_id=task.node_instance_id,
event_type=str(event_type).strip(),
actor_id=normalized_actor,
actor_name=str(actor_name or actor_id or "anonymous").strip() or "anonymous",
actor_type=str(actor_type or "user").strip(),
request_id=normalized_request,
expected_task_version=int(expected_task_version),
result_task_version=int(task.version),
payload_fingerprint=fingerprint,
payload_json=dict(payload),
before_json=dict(before_json),
after_json=task_state(task),
response_json={},
approval_action_ledger_id=approval_action_ledger_id,
business_event_id=business_event_id,
correlation_id=correlation_id,
causation_id=causation_id,
)
event.occurred_at = occurred_at or datetime.now(UTC)
response = ApprovalTaskMutationRead(
task=task_read(task, permission=permission),
event=ApprovalTaskEventRead.model_validate(event),
related_tasks=[task_read(item) for item in related_tasks],
replayed=False,
)
event.response_json = response.model_dump(mode="json")
self.db.add(event)
return event, response
def _response_from_event(self, event: ApprovalTaskEvent) -> ApprovalTaskMutationRead:
try:
response = ApprovalTaskMutationRead.model_validate(event.response_json)
except (TypeError, ValueError, ValidationError) as error:
raise ApprovalTaskConflictError(
"审批任务事件缺少可验证的原始响应快照,请刷新后重试。"
) from error
if (
response.event.id != event.id
or response.event.task_id != event.task_id
or response.event.request_id != event.request_id
or response.event.result_task_version != event.result_task_version
or response.task.id != event.task_id
or response.task.tenant_id != event.tenant_id
or response.task.version != event.result_task_version
):
raise ApprovalTaskConflictError("审批任务事件响应快照身份校验失败。")
return response
def task_read(
task: ApprovalTask,
*,
permission: ApprovalTaskPermission | None = None,
) -> ApprovalTaskRead:
update: dict[str, Any] = {}
if permission is not None:
update = {
"can_act": permission.can_act,
"available_actions": list(permission.available_actions),
"read_only_reason": permission.read_only_reason,
}
return ApprovalTaskRead.model_validate(task).model_copy(update=update)
def task_state(task: ApprovalTask) -> dict[str, Any]:
return {
"status": task.status,
"decision": task.decision,
"opinion": task.opinion,
"version": task.version,
"coordination_mode": task.coordination_mode,
"owner_kind": task.owner_kind,
"owner_key": task.owner_key,
"owner_employee_id": task.owner_employee_id,
"owner_name": task.owner_name,
"assignee_kind": task.assignee_kind,
"assignee_key": task.assignee_key,
"assignee_employee_id": task.assignee_employee_id,
"assignee_name": task.assignee_name,
"delegated_by": task.delegated_by,
"delegation_expires_at": _json_default(task.delegation_expires_at),
"claim_status_snapshot": task.claim_status_snapshot,
"claim_stage_snapshot": task.claim_stage_snapshot,
"activated_at": _json_default(task.activated_at),
"completed_at": _json_default(task.completed_at),
"cancelled_at": _json_default(task.cancelled_at),
"escalation_level": task.escalation_level,
"escalated_at": _json_default(task.escalated_at),
"next_escalation_at": _json_default(task.next_escalation_at),
"projection_updated_at": _json_default(task.projection_updated_at),
}
def _json_default(value: object) -> object:
if isinstance(value, datetime):
return value.isoformat()
return value

View File

@@ -0,0 +1,278 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import CurrentUserContext
from app.models.approval_task import ApprovalTask
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.schemas.approval_task import (
ApprovalTaskListRead,
ApprovalTaskQueueItemRead,
)
from app.schemas.reimbursement import ExpenseClaimRead
from app.services.approval_task_access_policy import ApprovalTaskAccessPolicy
from app.services.approval_task_protocol import task_read
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
class ApprovalTaskQueryService:
"""服务端分页的个人审批队列GET 路径严格只读。"""
def __init__(self, db: Session) -> None:
self.db = db
self.task_access = ApprovalTaskAccessPolicy(db)
self.claim_access = ExpenseClaimAccessPolicy(db)
def list_tasks(
self,
current_user: CurrentUserContext,
*,
page: int = 1,
page_size: int = 20,
status: str = "pending",
risk_level: str | None = None,
sla_state: str | None = None,
node_key: str | None = None,
task_kind: str | None = None,
batch_eligible: bool | None = None,
assignee: str | None = None,
sort: str = "priority_desc",
keyword: str | None = None,
) -> ApprovalTaskListRead:
page = max(int(page), 1)
page_size = min(max(int(page_size), 1), 200)
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
now = datetime.now(UTC)
conditions = [
ApprovalTask.tenant_id == tenant,
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant),
self._status_condition(status),
]
visibility = self._visibility_condition(current_user)
if visibility is not None:
conditions.append(visibility)
if risk_level:
conditions.append(ApprovalTask.risk_level == str(risk_level).strip().lower())
if node_key:
conditions.append(ApprovalTask.node_key == str(node_key).strip().lower())
if task_kind:
normalized_kind = str(task_kind).strip().lower()
if normalized_kind not in {"root", "add_sign", "countersign"}:
raise ValueError("不支持的审批任务类型筛选。")
conditions.append(ApprovalTask.task_kind == normalized_kind)
if batch_eligible is not None:
conditions.append(ApprovalTask.batch_eligible.is_(bool(batch_eligible)))
normalized_assignee = str(assignee or "").strip()
if normalized_assignee:
assignee_pattern = f"%{normalized_assignee}%"
conditions.append(
or_(
ApprovalTask.assignee_name.ilike(assignee_pattern),
ApprovalTask.assignee_key.ilike(assignee_pattern),
)
)
normalized_sla_state = str(sla_state or "").strip().lower()
if normalized_sla_state == "overdue":
conditions.append(ApprovalTask.due_at < now)
elif normalized_sla_state == "due_soon":
conditions.extend(
(
ApprovalTask.due_at >= now,
ApprovalTask.due_at <= now + timedelta(hours=4),
)
)
elif normalized_sla_state == "on_track":
conditions.append(ApprovalTask.due_at > now + timedelta(hours=4))
elif normalized_sla_state == "escalated":
conditions.append(ApprovalTask.escalation_level > 0)
elif normalized_sla_state:
raise ValueError("不支持的审批任务 SLA 状态筛选。")
normalized_keyword = str(keyword or "").strip()
if normalized_keyword:
pattern = f"%{normalized_keyword}%"
conditions.append(
or_(
ExpenseClaim.claim_no.ilike(pattern),
ExpenseClaim.employee_name.ilike(pattern),
ExpenseClaim.reason.ilike(pattern),
ApprovalTask.assignee_name.ilike(pattern),
ApprovalTask.owner_name.ilike(pattern),
)
)
total = int(
self.db.scalar(
select(func.count())
.select_from(ApprovalTask)
.join(ExpenseClaim, ExpenseClaim.id == ApprovalTask.claim_id)
.where(*conditions)
)
or 0
)
statement = (
select(ApprovalTask, ExpenseClaim)
.join(ExpenseClaim, ExpenseClaim.id == ApprovalTask.claim_id)
.options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
)
.where(*conditions)
.order_by(*self._ordering(sort))
.offset((page - 1) * page_size)
.limit(page_size)
)
items: list[ApprovalTaskQueueItemRead] = []
for task, claim in self.db.execute(statement).unique().all():
permission = self.task_access.evaluate(task, current_user, claim=claim)
if not permission.can_read: # SQL 与领域策略双重校验。
continue
self.claim_access.attach_approval_snapshot(claim)
items.append(
ApprovalTaskQueueItemRead(
task=task_read(task, permission=permission),
claim=ExpenseClaimRead.model_validate(claim),
)
)
return ApprovalTaskListRead(
items=items,
total=total,
page=page,
page_size=page_size,
total_pages=(total + page_size - 1) // page_size if total else 0,
generated_at=now,
)
def get_task(
self,
task_id: str,
current_user: CurrentUserContext,
) -> ApprovalTaskQueueItemRead | None:
tenant = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
row = self.db.execute(
select(ApprovalTask, ExpenseClaim)
.join(ExpenseClaim, ExpenseClaim.id == ApprovalTask.claim_id)
.options(
selectinload(ExpenseClaim.items),
selectinload(ExpenseClaim.employee).selectinload(Employee.manager),
selectinload(ExpenseClaim.employee).selectinload(Employee.roles),
selectinload(ExpenseClaim.employee).selectinload(Employee.organization_unit),
)
.where(
ApprovalTask.tenant_id == tenant,
ApprovalTask.id == str(task_id).strip(),
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant),
)
).unique().one_or_none()
if row is None:
return None
task, claim = row
permission = self.task_access.evaluate(task, current_user, claim=claim)
if not permission.can_read:
return None
self.claim_access.attach_approval_snapshot(claim)
return ApprovalTaskQueueItemRead(
task=task_read(task, permission=permission),
claim=ExpenseClaimRead.model_validate(claim),
)
def _visibility_condition(self, current_user: CurrentUserContext):
if current_user.is_admin:
return None
employee = self.claim_access.resolve_current_employee(current_user)
identities = {
str(current_user.username or "").strip().casefold(),
str(current_user.employee_id or "").strip().casefold(),
str(current_user.employee_no or "").strip().casefold(),
str(current_user.name or "").strip().casefold(),
}
employee_ids: set[str] = set()
if employee is not None:
identities.update(
{
str(employee.id or "").strip().casefold(),
str(employee.employee_no or "").strip().casefold(),
str(employee.email or "").strip().casefold(),
str(employee.name or "").strip().casefold(),
}
)
employee_ids.add(employee.id)
if current_user.employee_id:
employee_ids.add(str(current_user.employee_id).strip())
identities.discard("")
role_codes = ExpenseClaimAccessPolicy.normalize_role_codes(current_user)
employee_match = or_(
and_(
ApprovalTask.assignee_kind == "employee",
or_(
func.lower(ApprovalTask.assignee_key).in_(identities),
ApprovalTask.assignee_employee_id.in_(employee_ids),
),
),
and_(
ApprovalTask.owner_kind == "employee",
or_(
func.lower(ApprovalTask.owner_key).in_(identities),
ApprovalTask.owner_employee_id.in_(employee_ids),
),
),
)
role_match = or_(
and_(
ApprovalTask.assignee_kind == "role",
func.lower(ApprovalTask.assignee_key).in_(role_codes),
),
and_(
ApprovalTask.owner_kind == "role",
func.lower(ApprovalTask.owner_key).in_(role_codes),
),
)
return or_(employee_match, role_match)
@staticmethod
def _status_condition(value: str):
normalized = str(value or "pending").strip().lower()
if normalized in {"open", "pending"}:
return ApprovalTask.status.in_(("waiting", "pending"))
if normalized == "processed":
return ApprovalTask.status.in_(("completed", "returned", "cancelled", "superseded"))
if normalized in {
"waiting",
"completed",
"returned",
"cancelled",
"superseded",
}:
return ApprovalTask.status == normalized
raise ValueError("不支持的审批任务状态筛选。")
@staticmethod
def _ordering(value: str):
normalized = str(value or "priority_desc").strip().lower()
if normalized == "priority_desc":
return (
ApprovalTask.priority_score.desc(),
ApprovalTask.due_at.asc(),
ApprovalTask.entered_at.asc(),
ApprovalTask.id.asc(),
)
if normalized == "due_asc":
return (
ApprovalTask.due_at.asc(),
ApprovalTask.priority_score.desc(),
ApprovalTask.entered_at.asc(),
ApprovalTask.id.asc(),
)
if normalized == "entered_desc":
return (
ApprovalTask.entered_at.desc(),
ApprovalTask.priority_score.desc(),
ApprovalTask.id.asc(),
)
raise ValueError("不支持的审批任务排序方式。")

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
import os
import threading
from app.core.logging import get_logger
from app.db.session import get_session_factory
from app.services.approval_task_sla import ApprovalTaskSlaService
logger = get_logger("app.services.approval_task_scheduler")
class ApprovalTaskScheduler:
def __init__(self) -> None:
interval = int(os.environ.get("X_FINANCIAL_APPROVAL_TASK_INTERVAL_SECONDS") or "60")
initial_delay = int(
os.environ.get("X_FINANCIAL_APPROVAL_TASK_INITIAL_DELAY_SECONDS") or "10"
)
self._interval_seconds = max(30, interval)
self._initial_delay_seconds = max(1, initial_delay)
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
def start(self) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run_loop,
name="approval-task-scheduler",
daemon=True,
)
self._thread.start()
logger.info(
"Approval task scheduler started interval=%ss",
self._interval_seconds,
)
def shutdown(self) -> None:
with self._lock:
thread = self._thread
self._thread = None
self._stop_event.set()
if thread is not None and thread.is_alive():
thread.join(timeout=3)
logger.info("Approval task scheduler stopped")
def _run_loop(self) -> None:
if self._stop_event.wait(self._initial_delay_seconds):
return
while not self._stop_event.is_set():
try:
self._run_once()
except Exception: # pragma: no cover - scheduler best effort logging
logger.exception("Scheduled approval task SLA escalation failed")
if self._stop_event.wait(self._interval_seconds):
break
@staticmethod
def _run_once() -> None:
db = get_session_factory()()
try:
escalated = ApprovalTaskSlaService(db).escalate_due_tasks()
db.commit()
if escalated:
logger.info("Approval task SLA escalated count=%s", len(escalated))
except Exception:
db.rollback()
raise
finally:
db.close()
approval_task_scheduler = ApprovalTaskScheduler()

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.approval_task import ApprovalTask
from app.models.financial_record import ExpenseClaim
from app.services.approval_task_protocol import ApprovalTaskEventProtocol, task_state
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
MAX_ESCALATION_LEVEL = 3
class ApprovalTaskSlaService:
"""按节点进入时间快照升级超时任务,不改变审批权限和 Claim 状态。"""
def __init__(self, db: Session) -> None:
self.db = db
self.events = ApprovalTaskEventProtocol(db)
def escalate_due_tasks(
self,
*,
now: datetime | None = None,
limit: int = 100,
) -> list[str]:
checked_at = _as_utc(now or datetime.now(UTC))
locators = list(
self.db.execute(
select(
ApprovalTask.id,
ApprovalTask.claim_id,
ApprovalTask.tenant_id,
)
.where(
ApprovalTask.status.in_(("waiting", "pending")),
ApprovalTask.next_escalation_at.is_not(None),
ApprovalTask.next_escalation_at <= checked_at,
ApprovalTask.escalation_level < MAX_ESCALATION_LEVEL,
)
.order_by(
ApprovalTask.next_escalation_at.asc(),
ApprovalTask.priority_score.desc(),
ApprovalTask.id.asc(),
)
.limit(min(max(int(limit), 1), 500))
).all()
)
escalated: list[str] = []
for task_id, claim_id, tenant_id in locators:
if self._escalate_one(
task_id=str(task_id),
claim_id=str(claim_id),
tenant_id=str(tenant_id),
now=checked_at,
):
escalated.append(str(task_id))
return escalated
def _escalate_one(
self,
*,
task_id: str,
claim_id: str,
tenant_id: str,
now: datetime,
) -> bool:
claim_statement = select(ExpenseClaim).where(
ExpenseClaim.id == claim_id,
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant_id),
)
task_statement = select(ApprovalTask).where(
ApprovalTask.tenant_id == tenant_id,
ApprovalTask.id == task_id,
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
claim_statement = claim_statement.with_for_update()
task_statement = task_statement.with_for_update()
claim = self.db.scalar(claim_statement.execution_options(populate_existing=True))
if claim is None:
return False
task = self.db.scalar(task_statement.execution_options(populate_existing=True))
if (
task is None
or task.status not in {"waiting", "pending"}
or task.next_escalation_at is None
or _as_utc(task.next_escalation_at) > now
or task.escalation_level >= MAX_ESCALATION_LEVEL
):
return False
before = task_state(task)
expected = task.version
task.escalation_level += 1
task.escalated_at = now
task.version += 1
task.updated_at = now
task.next_escalation_at = (
None
if task.escalation_level >= MAX_ESCALATION_LEVEL
else now + timedelta(hours=4 * (2 ** (task.escalation_level - 1)))
)
task.priority_score = min(100, int(task.priority_score) + 10)
task.priority_tier = "urgent" if task.priority_score >= 65 else "high"
reasons = list(task.priority_reasons_json or [])
reasons.append(
{
"code": f"sla_escalated_l{task.escalation_level}",
"label": f"审批超时已升级至 L{task.escalation_level}",
"weight": 10,
"tone": "danger",
}
)
task.priority_reasons_json = reasons
self.events.append_event(
task=task,
event_type="sla_escalated",
actor_id="system",
actor_name="审批 SLA 调度器",
actor_type="system",
request_id=f"sla:{task.id}:{task.version}",
expected_task_version=expected,
payload={
"level": task.escalation_level,
"due_at": task.due_at.isoformat(),
"next_escalation_at": (
task.next_escalation_at.isoformat()
if task.next_escalation_at is not None
else None
),
},
before_json=before,
occurred_at=now,
)
# 项目 Session 关闭了 autoflush同一事务再次扫描前必须让版本、
# 下次升级时间和唯一事件版本同时可见。
self.db.flush()
return True
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -8,6 +8,7 @@ from sqlalchemy import select
from app.api.deps import CurrentUserContext
from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask
from app.models.financial_record import ExpenseClaim
from app.schemas.reimbursement import ExpenseClaimRead
from app.services.approval_action_protocol import (
@@ -34,6 +35,8 @@ class ExpenseClaimActionProtocolMixin:
expected_approval_stage: str | None,
payload: Mapping[str, Any],
executor: ClaimActionExecutor,
task_id: str | None = None,
expected_task_version: int | None = None,
) -> ExpenseClaim | ExpenseClaimRead | None:
protocol = ApprovalActionProtocol(self.db)
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id)
@@ -55,6 +58,7 @@ class ExpenseClaimActionProtocolMixin:
claim_loader=lambda: self._load_claim_for_action(
claim_id,
current_user,
task_id=task_id,
),
)
if started.replayed:
@@ -69,11 +73,48 @@ class ExpenseClaimActionProtocolMixin:
if started.ledger is None: # pragma: no cover - defensive invariant
raise RuntimeError("审批动作账本初始化失败。")
task_lifecycle = None
task_context = None
if action in {"approve", "return"}:
from app.services.approval_task_lifecycle import (
ApprovalTaskLifecycleService,
)
task_lifecycle = ApprovalTaskLifecycleService(self.db)
task_context = task_lifecycle.begin_claim_decision(
started.claim,
current_user,
action=action,
task_id=task_id,
expected_task_version=expected_task_version,
)
started.claim._approval_task_authorized = True
claim = executor(
started.claim,
started.ledger,
normalized_request_id,
)
if task_lifecycle is not None and task_context is not None:
opinion = str(
payload.get("opinion")
or payload.get("reason")
or ""
).strip()
task_lifecycle.complete_claim_decision(
task_context,
claim,
current_user,
action=action,
opinion=opinion,
request_id=normalized_request_id,
ledger=started.ledger,
business_event=getattr(
claim,
"_approval_task_business_event",
None,
),
)
self.db.flush()
self.db.refresh(claim)
response = ExpenseClaimRead.model_validate(
@@ -96,13 +137,31 @@ class ExpenseClaimActionProtocolMixin:
self,
claim_id: str,
current_user: CurrentUserContext,
*,
task_id: str | None = None,
) -> ExpenseClaim | None:
stmt = select(ExpenseClaim).where(ExpenseClaim.id == claim_id)
stmt = self._access_policy.apply_claim_scope(
stmt,
current_user,
include_approval_scope=True,
)
if task_id:
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(
current_user.tenant_id
)
task_claim_id = self.db.scalar(
select(ApprovalTask.claim_id).where(
ApprovalTask.tenant_id == tenant_id,
ApprovalTask.id == str(task_id).strip(),
)
)
if str(task_claim_id or "") != str(claim_id):
return None
stmt = stmt.where(
ExpenseClaimTenantScopeMixin.build_claim_tenant_condition(tenant_id)
)
else:
stmt = self._access_policy.apply_claim_scope(
stmt,
current_user,
include_approval_scope=True,
)
bind = self.db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
stmt = stmt.with_for_update()

View File

@@ -36,6 +36,8 @@ class ExpenseClaimApprovalFlowMixin:
request_id: str | None = None,
expected_status: str | None = None,
expected_approval_stage: str | None = None,
task_id: str | None = None,
expected_task_version: int | None = None,
):
normalized_opinion = str(opinion or "").strip()
return self._execute_claim_action(
@@ -45,7 +47,11 @@ class ExpenseClaimApprovalFlowMixin:
request_id=request_id,
expected_status=expected_status,
expected_approval_stage=expected_approval_stage,
payload={"opinion": normalized_opinion},
payload={
"opinion": normalized_opinion,
"task_id": str(task_id or "").strip() or None,
"expected_task_version": expected_task_version,
},
executor=lambda claim, ledger, normalized_request_id: self._approve_claim_once(
claim,
current_user,
@@ -53,6 +59,8 @@ class ExpenseClaimApprovalFlowMixin:
ledger=ledger,
request_id=normalized_request_id,
),
task_id=task_id,
expected_task_version=expected_task_version,
)
def _approve_claim_once(
@@ -77,7 +85,9 @@ class ExpenseClaimApprovalFlowMixin:
route_decision_flag: dict[str, Any] | None = None
generated_draft = None
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim):
if not getattr(claim, "_approval_task_authorized", False) and not (
self._access_policy.can_approve_claim(current_user, claim)
):
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
self._ensure_claim_has_no_blocking_risk(claim, current_user)
approval_source = "manual_approval"
@@ -154,7 +164,9 @@ class ExpenseClaimApprovalFlowMixin:
"{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}"
)
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim):
if not getattr(claim, "_approval_task_authorized", False) and not (
self._access_policy.can_approve_claim(current_user, claim)
):
raise ValueError("只有当前预算管理者可以审批通过该单据。")
self._ensure_claim_has_no_blocking_risk(claim, current_user)
approval_source = "budget_approval"
@@ -175,7 +187,9 @@ class ExpenseClaimApprovalFlowMixin:
elif previous_stage == FINANCE_APPROVAL_STAGE:
if is_application_claim:
raise ValueError("费用申请需先完成预算管理者审批。")
if not self._access_policy.can_approve_claim(current_user, claim):
if not getattr(claim, "_approval_task_authorized", False) and not (
self._access_policy.can_approve_claim(current_user, claim)
):
raise ValueError("只有财务人员可以完成财务终审。")
self._ensure_claim_has_no_blocking_risk(claim, current_user)
approval_source = "finance_approval"
@@ -340,6 +354,7 @@ class ExpenseClaimApprovalFlowMixin:
),
},
)
claim._approval_task_business_event = _event
if generated_draft is not None:
self._expense_cases.record_claim_event(
generated_draft,

View File

@@ -21,6 +21,8 @@ class ExpenseClaimReturnFlowMixin:
request_id: str | None = None,
expected_status: str | None = None,
expected_approval_stage: str | None = None,
task_id: str | None = None,
expected_task_version: int | None = None,
) -> ExpenseClaim | ExpenseClaimRead | None:
normalized_reason = str(reason or "").strip()
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
@@ -35,7 +37,12 @@ class ExpenseClaimReturnFlowMixin:
request_id=request_id,
expected_status=expected_status,
expected_approval_stage=expected_approval_stage,
payload={"reason": normalized_reason, "reason_codes": normalized_codes},
payload={
"reason": normalized_reason,
"reason_codes": normalized_codes,
"task_id": str(task_id or "").strip() or None,
"expected_task_version": expected_task_version,
},
executor=lambda claim, ledger, normalized_request_id: self._return_claim_once(
claim,
current_user,
@@ -44,6 +51,8 @@ class ExpenseClaimReturnFlowMixin:
ledger=ledger,
request_id=normalized_request_id,
),
task_id=task_id,
expected_task_version=expected_task_version,
)
def _return_claim_once(
@@ -64,7 +73,9 @@ class ExpenseClaimReturnFlowMixin:
if normalized_status in {"approved", "completed", "paid"}:
raise ValueError("已完成单据不允许退回。")
if not self._access_policy.can_return_claim(current_user, claim):
if not getattr(claim, "_approval_task_authorized", False) and not (
self._access_policy.can_return_claim(current_user, claim)
):
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
before_json = self._serialize_claim(claim)
@@ -162,7 +173,7 @@ class ExpenseClaimReturnFlowMixin:
business_stage=("expense_application" if is_application_claim else "reimbursement"),
)
self._expense_cases.record_claim_event(
_, return_event = self._expense_cases.record_claim_event(
claim,
event_type=("application_returned" if is_application_claim else "claim_returned"),
actor_id=current_user.username,
@@ -175,6 +186,7 @@ class ExpenseClaimReturnFlowMixin:
"reason_codes": normalized_reason_codes,
},
)
claim._approval_task_business_event = return_event
self.audit_service.log_action(
actor=operator,
action="expense_claim.return",

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -167,7 +168,18 @@ class ExpenseClaimRiskGate:
if disposition is not None:
adjudication = str(disposition.adjudication or "").strip().lower()
lifecycle = str(disposition.lifecycle_status or "").strip().lower()
if lifecycle == "waived":
expires_at = _as_utc(disposition.waiver_expires_at)
return expires_at is not None and expires_at > datetime.now(UTC)
return adjudication == "false_positive" or lifecycle == "resolved"
status = str(observation.status or "").strip().lower()
feedback_status = str(observation.feedback_status or "").strip().lower()
return status in _LEGACY_CLOSED_STATUSES or feedback_status == "false_positive"
def _as_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -384,6 +384,18 @@ class ExpenseClaimItemActionMixin:
previous_status=str(before_json.get("status") or ""),
previous_approval_stage=str(before_json.get("approval_stage") or ""),
)
from app.services.approval_task_lifecycle import ApprovalTaskLifecycleService
ApprovalTaskLifecycleService(self.db).ensure_root_task(
claim,
tenant_id=current_user.tenant_id,
entered_at=submission_event.occurred_at,
entered_at_source="workflow_event",
business_event=submission_event,
actor_id="system",
actor_name="审批任务引擎",
request_id=f"node-enter:{submission_event.id}",
)
if before_commit is not None:
before_commit(submission_event)

View File

@@ -0,0 +1,214 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.schemas.risk_disposition import RiskDispositionRead
from app.schemas.risk_observation import RiskObservationRead
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_waiver_decision_policy import RiskWaiverDecisionPolicy
@dataclass(frozen=True, slots=True)
class RiskDispositionActionPermission:
available_actions: tuple[str, ...]
read_only_reason: str = ""
class RiskDispositionActionPolicy:
"""根据当前用户与实时处置状态计算服务端权威动作集合。"""
def __init__(self, db: Session) -> None:
self.db = db
self.access_policy = RiskObservationAccessPolicy(db)
self.waiver_policy = RiskWaiverDecisionPolicy(db)
def evaluate(
self,
observation: RiskObservation,
disposition: RiskDisposition | RiskDispositionRead | None,
current_user: CurrentUserContext | None,
*,
now: datetime | None = None,
) -> RiskDispositionActionPermission:
current_at = _as_utc(now) or datetime.now(UTC)
tenant_id = ExpenseClaimTenantScopeMixin.normalize_tenant_id(observation.tenant_id)
if current_user is None:
return RiskDispositionActionPermission((), "请先登录后再处理风险观察。")
if ExpenseClaimTenantScopeMixin.normalize_tenant_id(current_user.tenant_id) != tenant_id:
return RiskDispositionActionPermission((), "风险观察不在当前租户范围内。")
adjudication = _adjudication(observation, disposition)
lifecycle = _lifecycle(observation, disposition)
can_manage = self.access_policy.can_manage_disposition(observation, current_user)
actions: list[str] = []
if can_manage and lifecycle != "resolved":
if adjudication != "confirmed":
actions.append("confirm")
if adjudication != "false_positive":
actions.append("false_positive")
if lifecycle != "supplement_requested":
actions.append("request_supplement")
if adjudication == "confirmed":
if lifecycle != "remediation_in_progress":
actions.append("start_remediation")
if lifecycle != "waiver_requested" or not _text(
disposition.waiver_requester_id if disposition is not None else None
):
actions.append("request_waiver")
actions.append("resolve")
decision_reason = ""
if disposition is not None and lifecycle == "waiver_requested":
eligibility = self.waiver_policy.evaluate_decider(
disposition,
current_user=current_user,
tenant_id=tenant_id,
)
decision_reason = eligibility.reason
if eligibility.allowed:
expires_at = _as_utc(disposition.waiver_expires_at)
if expires_at is not None and expires_at > current_at:
actions.append("approve_waiver")
actions.append("reject_waiver")
available_actions = tuple(dict.fromkeys(actions))
if available_actions:
return RiskDispositionActionPermission(available_actions)
if lifecycle == "resolved":
reason = "该风险已经解决,当前仅可查看审计记录。"
elif lifecycle == "waived" and _is_unexpired(disposition, now=current_at):
reason = "该风险处于有效豁免期,当前仅可查看审计记录。"
elif lifecycle == "waiver_requested" and decision_reason:
reason = decision_reason
elif not can_manage:
reason = "当前用户只有查看权限,无风险处置权限。"
else:
reason = "当前状态没有可执行的风险处置动作。"
return RiskDispositionActionPermission((), reason)
class RiskDispositionReadProjection:
"""把同一权限投影注入风险列表、详情和处置详情响应。"""
def __init__(self, db: Session) -> None:
self.policy = RiskDispositionActionPolicy(db)
def observation_read(
self,
observation: RiskObservation,
current_user: CurrentUserContext,
) -> RiskObservationRead:
disposition = observation.disposition
permission = self.policy.evaluate(observation, disposition, current_user)
disposition_read = (
self._with_permission(
RiskDispositionRead.model_validate(disposition),
permission,
)
if disposition is not None
else None
)
return RiskObservationRead.model_validate(observation).model_copy(
update={
"disposition": disposition_read,
"available_actions": list(permission.available_actions),
"read_only_reason": permission.read_only_reason,
}
)
def observation_list_read(
self,
observations: list[RiskObservation],
current_user: CurrentUserContext,
) -> list[RiskObservationRead]:
return [self.observation_read(item, current_user) for item in observations]
def disposition_read(
self,
disposition: RiskDisposition,
observation: RiskObservation,
current_user: CurrentUserContext,
) -> RiskDispositionRead:
return self.disposition_snapshot_read(
RiskDispositionRead.model_validate(disposition),
observation,
current_user,
)
def disposition_snapshot_read(
self,
disposition: RiskDispositionRead,
observation: RiskObservation,
current_user: CurrentUserContext,
) -> RiskDispositionRead:
"""为幂等响应快照补权限字段,不改写快照中的历史业务状态。"""
permission = self.policy.evaluate(observation, disposition, current_user)
return self._with_permission(disposition, permission)
@staticmethod
def _with_permission(
disposition: RiskDispositionRead,
permission: RiskDispositionActionPermission,
) -> RiskDispositionRead:
return disposition.model_copy(
update={
"available_actions": list(permission.available_actions),
"read_only_reason": permission.read_only_reason,
}
)
def _adjudication(
observation: RiskObservation,
disposition: RiskDisposition | RiskDispositionRead | None,
) -> str:
if disposition is not None:
return _text(disposition.adjudication).lower() or "unreviewed"
values = {
_text(observation.feedback_status).lower(),
_text(observation.status).lower(),
}
if "false_positive" in values:
return "false_positive"
if "confirmed" in values:
return "confirmed"
return "unreviewed"
def _lifecycle(
observation: RiskObservation,
disposition: RiskDisposition | RiskDispositionRead | None,
) -> str:
if disposition is not None:
return _text(disposition.lifecycle_status).lower() or "open"
return "resolved" if _text(observation.status).lower() == "resolved" else "open"
def _is_unexpired(
disposition: RiskDisposition | RiskDispositionRead | None,
*,
now: datetime | None,
) -> bool:
expires_at = _as_utc(disposition.waiver_expires_at if disposition is not None else None)
current_at = _as_utc(now) or datetime.now(UTC)
return expires_at is not None and expires_at > current_at
def _as_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def _text(value: object) -> str:
return str(value or "").strip()

View File

@@ -24,12 +24,19 @@ from app.schemas.risk_disposition import (
RiskDispositionMutationRead,
RiskDispositionRead,
)
from app.services.approval_task_projection_refresh import (
ApprovalTaskProjectionRefreshService,
)
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
from app.services.risk_observation_access_policy import RiskObservationAccessPolicy
from app.services.risk_waiver_decision_policy import (
RISK_WAIVER_DECISION_ACTIONS,
RiskWaiverDecisionPermissionError,
RiskWaiverDecisionPolicy,
)
logger = get_logger("app.services.risk_dispositions")
class RiskDispositionConflictError(RuntimeError):
pass
@@ -145,12 +152,17 @@ class RiskDispositionService:
raise RiskDispositionConflictError(
"Risk observation claim changed concurrently; reload and retry."
)
if current_user is not None and not RiskObservationAccessPolicy(
self.db
).can_manage_locked_disposition(
observation,
current_user,
locked_claim=locked_claim,
is_waiver_decision = payload.action in RISK_WAIVER_DECISION_ACTIONS
if (
not is_waiver_decision
and current_user is not None
and not RiskObservationAccessPolicy(
self.db
).can_manage_locked_disposition(
observation,
current_user,
locked_claim=locked_claim,
)
):
raise RiskDispositionPermissionError("当前用户已不再是该单据的有效审批人。")
@@ -180,9 +192,29 @@ class RiskDispositionService:
)
self.db.add(disposition)
_validate_transition(disposition, payload)
waiver_policy = RiskWaiverDecisionPolicy(self.db)
waiver_actor_id, waiver_actor_name = waiver_policy.actor_identity(
current_user,
fallback_id=normalized_actor_id,
fallback_name=_text(actor_name) or normalized_actor_id,
)
_validate_transition(disposition, payload, now=mutation_at)
if is_waiver_decision:
waiver_actor_id, waiver_actor_name = waiver_policy.require_decider(
disposition,
current_user=current_user,
tenant_id=normalized_tenant,
)
before = _state(disposition)
_apply_action(disposition, observation, payload)
_apply_action(
disposition,
observation,
payload,
actor_id=waiver_actor_id,
actor_name=waiver_actor_name,
mutation_at=mutation_at,
)
disposition.version = current_version + 1
disposition.updated_at = mutation_at
event = RiskDispositionEvent(
@@ -192,11 +224,11 @@ class RiskDispositionService:
observation_id=observation.id,
version=disposition.version,
action=payload.action,
actor_id=normalized_actor_id,
actor_name=_text(actor_name) or _text(actor_id) or "anonymous",
actor_id=waiver_actor_id,
actor_name=waiver_actor_name,
request_id=payload.request_id,
payload_fingerprint=fingerprint,
comment=payload.comment,
comment=payload.comment or payload.waiver_reason,
before_json=before,
after_json=_state(disposition),
created_at=mutation_at,
@@ -216,6 +248,14 @@ class RiskDispositionService:
payload,
actor_name=actor_name,
)
# 风险事实与审批任务队列投影必须原子可见autoflush 关闭时先显式
# 落下 observation/disposition再按最新风险状态重算开放任务。
self.db.flush()
ApprovalTaskProjectionRefreshService(self.db).refresh_claim(
tenant_id=normalized_tenant,
claim_id=str(observation.claim_id or "").strip(),
now=mutation_at,
)
self.db.commit()
self.db.refresh(disposition)
self.db.refresh(event)
@@ -226,7 +266,12 @@ class RiskDispositionService:
response=response,
legacy_feedback=legacy_feedback,
)
except (LookupError, RiskDispositionConflictError, RiskDispositionPermissionError):
except (
LookupError,
RiskDispositionConflictError,
RiskDispositionPermissionError,
RiskWaiverDecisionPermissionError,
):
self.db.rollback()
raise
except IntegrityError as error:
@@ -292,6 +337,18 @@ class RiskDispositionService:
assignee=disposition.assignee,
due_at=disposition.due_at,
resolution=disposition.resolution,
waiver_requester_id=disposition.waiver_requester_id,
waiver_requester_name=disposition.waiver_requester_name,
waiver_requested_at=disposition.waiver_requested_at,
waiver_reason=disposition.waiver_reason,
waiver_scope=disposition.waiver_scope,
waiver_expires_at=disposition.waiver_expires_at,
waiver_conditions_json=list(disposition.waiver_conditions_json or []),
waiver_decision=disposition.waiver_decision,
waiver_decider_id=disposition.waiver_decider_id,
waiver_decider_name=disposition.waiver_decider_name,
waiver_decided_at=disposition.waiver_decided_at,
waiver_decision_reason=disposition.waiver_decision_reason,
created_at=disposition.created_at,
updated_at=disposition.updated_at,
events=event_history,
@@ -382,6 +439,18 @@ class RiskDispositionService:
assignee=state.get("assignee"),
due_at=state.get("due_at"),
resolution=state.get("resolution"),
waiver_requester_id=state.get("waiver_requester_id"),
waiver_requester_name=state.get("waiver_requester_name"),
waiver_requested_at=state.get("waiver_requested_at"),
waiver_reason=state.get("waiver_reason"),
waiver_scope=state.get("waiver_scope"),
waiver_expires_at=state.get("waiver_expires_at"),
waiver_conditions_json=state.get("waiver_conditions_json") or [],
waiver_decision=state.get("waiver_decision"),
waiver_decider_id=state.get("waiver_decider_id"),
waiver_decider_name=state.get("waiver_decider_name"),
waiver_decided_at=state.get("waiver_decided_at"),
waiver_decision_reason=state.get("waiver_decision_reason"),
created_at=history[0].created_at,
updated_at=event.created_at,
events=event_reads,
@@ -468,6 +537,10 @@ def _apply_action(
disposition: RiskDisposition,
observation: RiskObservation,
payload: RiskDispositionActionCreate,
*,
actor_id: str,
actor_name: str,
mutation_at: datetime,
) -> None:
if payload.action == "confirm":
disposition.adjudication = "confirmed"
@@ -479,11 +552,48 @@ def _apply_action(
observation.status = "false_positive"
observation.feedback_status = "false_positive"
return
if payload.action == "request_waiver":
disposition.lifecycle_status = "waiver_requested"
disposition.waiver_requester_id = actor_id
disposition.waiver_requester_name = actor_name
disposition.waiver_requested_at = mutation_at
disposition.waiver_reason = payload.waiver_reason
disposition.waiver_scope = payload.waiver_scope
disposition.waiver_expires_at = payload.waiver_expires_at
disposition.waiver_conditions_json = list(payload.waiver_conditions)
disposition.waiver_decision = None
disposition.waiver_decider_id = None
disposition.waiver_decider_name = None
disposition.waiver_decided_at = None
disposition.waiver_decision_reason = None
disposition.resolution = None
if payload.assignee is not None:
disposition.assignee = payload.assignee
if payload.due_at is not None:
disposition.due_at = payload.due_at
return
if payload.action == "approve_waiver":
disposition.lifecycle_status = "waived"
disposition.waiver_decision = "approved"
disposition.waiver_decider_id = actor_id
disposition.waiver_decider_name = actor_name
disposition.waiver_decided_at = mutation_at
disposition.waiver_decision_reason = payload.comment
observation.status = "waived"
return
if payload.action == "reject_waiver":
disposition.lifecycle_status = "waiver_rejected"
disposition.waiver_decision = "rejected"
disposition.waiver_decider_id = actor_id
disposition.waiver_decider_name = actor_name
disposition.waiver_decided_at = mutation_at
disposition.waiver_decision_reason = payload.comment
observation.status = "confirmed"
return
lifecycle_by_action = {
"request_supplement": "supplement_requested",
"start_remediation": "remediation_in_progress",
"request_waiver": "waiver_requested",
"resolve": "resolved",
}
disposition.lifecycle_status = lifecycle_by_action[payload.action]
@@ -501,7 +611,21 @@ def _apply_action(
def _validate_transition(
disposition: RiskDisposition,
payload: RiskDispositionActionCreate,
*,
now: datetime,
) -> None:
if payload.action in RISK_WAIVER_DECISION_ACTIONS:
if disposition.lifecycle_status != "waiver_requested":
raise RiskDispositionConflictError("只有待决定的豁免申请才能批准或拒绝。")
if not _text(disposition.waiver_requester_id):
raise RiskDispositionConflictError(
"历史豁免申请缺少职责分离所需的申请人身份,请重新提交申请。"
)
if payload.action == "approve_waiver":
expires_at = _as_utc(disposition.waiver_expires_at)
if expires_at is None or expires_at <= _as_utc(now):
raise RiskDispositionConflictError("风险豁免申请已过期,不能批准。")
return
if disposition.lifecycle_status == "resolved":
raise RiskDispositionConflictError("已解决的风险不能再次变更裁决或处置生命周期。")
if payload.action == "confirm":
@@ -519,7 +643,17 @@ def _validate_transition(
"resolve": "resolved",
}[payload.action]
if disposition.lifecycle_status == target_lifecycle:
raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。")
if payload.action == "request_waiver" and not _text(
disposition.waiver_requester_id
):
# 0014 前的旧申请没有申请人和有效期,允许以完整协议重新提交。
pass
else:
raise RiskDispositionConflictError("该风险已处于目标处置状态,请勿重复提交。")
if payload.action == "request_waiver":
expires_at = _as_utc(payload.waiver_expires_at)
if expires_at is None or expires_at <= _as_utc(now):
raise RiskDispositionConflictError("风险豁免有效期必须晚于申请时间。")
if payload.action == "request_supplement":
return
if disposition.adjudication != "confirmed":
@@ -533,6 +667,30 @@ def _state(disposition: RiskDisposition) -> dict[str, object]:
"assignee": disposition.assignee,
"due_at": disposition.due_at.isoformat() if disposition.due_at else None,
"resolution": disposition.resolution,
"waiver_requester_id": disposition.waiver_requester_id,
"waiver_requester_name": disposition.waiver_requester_name,
"waiver_requested_at": (
disposition.waiver_requested_at.isoformat()
if disposition.waiver_requested_at
else None
),
"waiver_reason": disposition.waiver_reason,
"waiver_scope": disposition.waiver_scope,
"waiver_expires_at": (
disposition.waiver_expires_at.isoformat()
if disposition.waiver_expires_at
else None
),
"waiver_conditions_json": list(disposition.waiver_conditions_json or []),
"waiver_decision": disposition.waiver_decision,
"waiver_decider_id": disposition.waiver_decider_id,
"waiver_decider_name": disposition.waiver_decider_name,
"waiver_decided_at": (
disposition.waiver_decided_at.isoformat()
if disposition.waiver_decided_at
else None
),
"waiver_decision_reason": disposition.waiver_decision_reason,
"version": disposition.version,
}
@@ -581,3 +739,11 @@ def _tenant(value: str) -> str:
def _text(value: object) -> str:
return str(value or "").strip()
def _as_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)

View File

@@ -18,6 +18,9 @@ from app.schemas.risk_observation import (
RiskObservationDashboardRead,
RiskObservationFeedbackCreate,
)
from app.services.approval_task_projection_refresh import (
ApprovalTaskProjectionRefreshService,
)
from app.services.expense_claim_risk_stage import normalize_risk_business_stage
from app.services.expense_claim_tenant_scope import ExpenseClaimTenantScopeMixin
@@ -50,13 +53,9 @@ class RiskObservationService:
cache_key = str(getattr(bind, "url", "") or id(bind))
if cache_key in self._storage_ready_cache:
return
Base.metadata.create_all(
bind=bind,
tables=[
RiskObservation.__table__,
RiskObservationFeedback.__table__,
],
tables=[RiskObservation.__table__, RiskObservationFeedback.__table__],
)
self._storage_ready_cache.add(cache_key)
@@ -88,7 +87,6 @@ class RiskObservationService:
claim_id,
tenant_id=normalized_tenant_id,
)
item = self.db.scalar(
select(RiskObservation).where(
RiskObservation.tenant_id == normalized_tenant_id,
@@ -101,7 +99,6 @@ class RiskObservationService:
observation_key=observation_key,
)
self.db.add(item)
item.subject_type = _text(payload.get("subject_type"))
item.subject_key = _text(payload.get("subject_key"))
item.subject_label = _text(payload.get("subject_label"))
@@ -130,8 +127,12 @@ class RiskObservationService:
item.similar_case_claim_ids_json = _list(payload.get("similar_case_claim_ids"))
item.ontology_json = _risk_ontology_payload(payload)
item.decision_trace_json = _risk_decision_trace_payload(payload)
self.db.flush()
if item.claim_id:
ApprovalTaskProjectionRefreshService(self.db).refresh_claim(
tenant_id=normalized_tenant_id,
claim_id=item.claim_id,
)
return item
def lock_claim_for_risk_write(

View File

@@ -0,0 +1,154 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.employee import Employee
from app.models.risk_disposition import RiskDisposition
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
RISK_WAIVER_DECISION_ACTIONS = frozenset({"approve_waiver", "reject_waiver"})
_DECISION_ROLE_CODES = {"finance", "executive"}
_ACTIVE_EMPLOYMENT_STATUSES = {"active", "employed", "在职", "正常"}
class RiskWaiverDecisionPermissionError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class RiskWaiverDecisionEligibility:
allowed: bool
actor_id: str = ""
actor_name: str = ""
reason: str = ""
class RiskWaiverDecisionPolicy:
"""集中校验风险豁免决定人的租户、在职、角色和职责分离边界。"""
def __init__(self, db: Session) -> None:
self.db = db
def actor_identity(
self,
current_user: CurrentUserContext | None,
*,
fallback_id: str,
fallback_name: str,
) -> tuple[str, str]:
employee = self._resolve_actor_employee(current_user)
if employee is None:
return (
_text(fallback_id) or "anonymous",
_text(fallback_name) or _text(fallback_id) or "anonymous",
)
return employee.id, _text(employee.name) or _text(fallback_name) or employee.id
def require_decider(
self,
disposition: RiskDisposition,
*,
current_user: CurrentUserContext | None,
tenant_id: str,
) -> tuple[str, str]:
eligibility = self.evaluate_decider(
disposition,
current_user=current_user,
tenant_id=tenant_id,
)
if not eligibility.allowed:
raise RiskWaiverDecisionPermissionError(eligibility.reason)
return eligibility.actor_id, eligibility.actor_name
def evaluate_decider(
self,
disposition: RiskDisposition,
*,
current_user: CurrentUserContext | None,
tenant_id: str,
) -> RiskWaiverDecisionEligibility:
if current_user is None:
return RiskWaiverDecisionEligibility(
False,
reason="风险豁免决定必须由已登录处理人执行。",
)
if _tenant(current_user.tenant_id) != tenant_id:
return RiskWaiverDecisionEligibility(
False,
reason="不能跨租户处理风险豁免决定。",
)
employee = self._resolve_actor_employee(current_user)
if employee is None:
return RiskWaiverDecisionEligibility(
False,
reason="风险豁免决定必须由企业在职员工执行。",
)
employment_status = _text(employee.employment_status).lower()
if employment_status not in _ACTIVE_EMPLOYMENT_STATUSES:
return RiskWaiverDecisionEligibility(
False,
reason="当前处理人不是在职员工,不能决定风险豁免。",
)
role_codes = ExpenseClaimAccessPolicy.normalize_role_codes(current_user)
role_codes.update(
_text(role.role_code).lower()
for role in list(employee.roles or [])
if _text(role.role_code)
)
if not role_codes & _DECISION_ROLE_CODES:
return RiskWaiverDecisionEligibility(
False,
reason="只有具有 finance 或 executive 明确角色的在职处理人可以决定风险豁免。",
)
requester_id = _text(disposition.waiver_requester_id)
if not requester_id:
return RiskWaiverDecisionEligibility(
False,
reason="历史豁免申请缺少申请人身份,不能执行豁免决定。",
)
actor_aliases = {
employee.id.casefold(),
_text(employee.email).casefold(),
_text(employee.employee_no).casefold(),
_text(current_user.employee_id).casefold(),
_text(current_user.username).casefold(),
}
actor_aliases.discard("")
if requester_id.casefold() in actor_aliases:
return RiskWaiverDecisionEligibility(
False,
reason="风险豁免申请人不能批准或拒绝自己的申请。",
)
return RiskWaiverDecisionEligibility(
True,
actor_id=employee.id,
actor_name=_text(employee.name) or employee.id,
)
def _resolve_actor_employee(
self,
current_user: CurrentUserContext | None,
) -> Employee | None:
if current_user is None:
return None
employee_id = _text(current_user.employee_id)
if employee_id:
employee = self.db.get(Employee, employee_id)
if employee is not None:
return employee
return ExpenseClaimAccessPolicy(self.db).resolve_current_employee(current_user)
def _tenant(value: str) -> str:
return _text(value) or "default"
def _text(value: object) -> str:
return str(value or "").strip()