feat(approval): add task workflow and waiver decisions
This commit is contained in:
394
server/src/app/api/v1/endpoints/approval_tasks.py
Normal file
394
server/src/app/api/v1/endpoints/approval_tasks.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
361
server/src/app/models/approval_task.py
Normal file
361
server/src/app/models/approval_task.py
Normal 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")
|
||||
@@ -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"),
|
||||
|
||||
279
server/src/app/schemas/approval_task.py
Normal file
279
server/src/app/schemas/approval_task.py
Normal 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 = ""
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
266
server/src/app/services/approval_task_access_policy.py
Normal file
266
server/src/app/services/approval_task_access_policy.py
Normal 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)
|
||||
762
server/src/app/services/approval_task_actions.py
Normal file
762
server/src/app/services/approval_task_actions.py
Normal 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}"
|
||||
761
server/src/app/services/approval_task_backfill.py
Normal file
761
server/src/app/services/approval_task_backfill.py
Normal 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")
|
||||
207
server/src/app/services/approval_task_batch.py
Normal file
207
server/src/app/services/approval_task_batch.py
Normal 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}"
|
||||
499
server/src/app/services/approval_task_lifecycle.py
Normal file
499
server/src/app/services/approval_task_lifecycle.py
Normal 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
|
||||
566
server/src/app/services/approval_task_projection.py
Normal file
566
server/src/app/services/approval_task_projection.py
Normal 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)
|
||||
117
server/src/app/services/approval_task_projection_refresh.py
Normal file
117
server/src/app/services/approval_task_projection_refresh.py
Normal 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)
|
||||
223
server/src/app/services/approval_task_protocol.py
Normal file
223
server/src/app/services/approval_task_protocol.py
Normal 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
|
||||
278
server/src/app/services/approval_task_query.py
Normal file
278
server/src/app/services/approval_task_query.py
Normal 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("不支持的审批任务排序方式。")
|
||||
76
server/src/app/services/approval_task_scheduler.py
Normal file
76
server/src/app/services/approval_task_scheduler.py
Normal 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()
|
||||
147
server/src/app/services/approval_task_sla.py
Normal file
147
server/src/app/services/approval_task_sla.py
Normal 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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
214
server/src/app/services/risk_disposition_read_projection.py
Normal file
214
server/src/app/services/risk_disposition_read_projection.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
154
server/src/app/services/risk_waiver_decision_policy.py
Normal file
154
server/src/app/services/risk_waiver_decision_policy.py
Normal 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()
|
||||
Reference in New Issue
Block a user