feat: add conditional multi-level leave approval

This commit is contained in:
selfrelease
2026-07-18 08:49:02 +08:00
parent e46aa00e80
commit 4741fb26c8
12 changed files with 103 additions and 9 deletions
@@ -20,6 +20,7 @@ import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.Instant
import java.time.Duration
import java.util.UUID
@Service
@@ -71,11 +72,21 @@ class LeaveRequestService(
@Transactional
fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
val existing = getOwn(actor, id)
val durationMinutes = Duration.between(existing.startsAt, existing.endsAt).toMinutes()
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
if (approverId == actor.id) {
throw ApiException(HttpStatus.CONFLICT, "SELF_APPROVAL_NOT_ALLOWED", "申请人不能审批自己的申请")
}
val oaAdministratorId = routingRepository.findOaAdministrator(actor.tenantId)
?: throw ApiException(HttpStatus.CONFLICT, "OA_ADMIN_NOT_FOUND", "未找到有效的 OA 管理员")
if (durationMinutes > 1440 && oaAdministratorId == actor.id) {
throw ApiException(HttpStatus.CONFLICT, "SELF_APPROVAL_NOT_ALLOWED", "申请人不能复核自己的长期请假")
}
if (durationMinutes > 1440 && oaAdministratorId == approverId) {
throw ApiException(HttpStatus.CONFLICT, "APPROVER_SEPARATION_REQUIRED", "长期请假的主管和 OA 复核人必须为不同人员")
}
val outcome = transition(
actor = actor,
id = id,
@@ -93,6 +104,8 @@ class LeaveRequestService(
outcome.leaveRequest.id,
actor.id,
approverId,
oaAdministratorId,
durationMinutes,
)
repository.attachWorkflow(
actor.tenantId,
@@ -2,6 +2,8 @@ package com.all8ai.aioa.approval.domain
import java.util.UUID
fun interface ApprovalRoutingRepository {
interface ApprovalRoutingRepository {
fun findDepartmentManager(tenantId: UUID, applicantId: UUID): UUID?
fun findOaAdministrator(tenantId: UUID): UUID?
}
@@ -39,4 +39,27 @@ class JooqApprovalRoutingRepository(
tenantId,
applicantId,
)?.get("user_id", UUID::class.java)
override fun findOaAdministrator(tenantId: UUID): UUID? =
dsl.fetchOne(
"""
SELECT ur.user_id
FROM authz.user_role ur
JOIN authz.role role
ON role.tenant_id = ur.tenant_id
AND role.id = ur.role_id
AND role.code = 'oa_admin'
AND role.status = 'ACTIVE'
JOIN identity.user_account account
ON account.tenant_id = ur.tenant_id
AND account.id = ur.user_id
AND account.status = 'ACTIVE'
WHERE ur.tenant_id = ?
AND ur.effective_from <= CURRENT_TIMESTAMP
AND (ur.effective_until IS NULL OR ur.effective_until > CURRENT_TIMESTAMP)
ORDER BY ur.effective_from, ur.user_id
LIMIT 1
""".trimIndent(),
tenantId,
)?.get("user_id", UUID::class.java)
}
@@ -9,6 +9,8 @@ interface LeaveWorkflowGateway {
leaveRequestId: UUID,
applicantId: UUID,
approverId: UUID,
oaAdministratorId: UUID,
durationMinutes: Long,
): StartedProcess
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
@@ -22,6 +22,8 @@ class FlowableLeaveWorkflowGateway(
leaveRequestId: UUID,
applicantId: UUID,
approverId: UUID,
oaAdministratorId: UUID,
durationMinutes: Long,
): StartedProcess {
val process = runtimeService.createProcessInstanceBuilder()
.processDefinitionKey(PROCESS_DEFINITION_KEY)
@@ -32,6 +34,8 @@ class FlowableLeaveWorkflowGateway(
"businessId" to leaveRequestId.toString(),
"applicantId" to applicantId.toString(),
"approverId" to approverId.toString(),
"oaAdministratorId" to oaAdministratorId.toString(),
"durationMinutes" to durationMinutes,
),
)
.start()
@@ -3,20 +3,41 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:flowable="http://flowable.org/bpmn"
targetNamespace="https://aioa.all8ai.com/processes">
<process id="leaveApproval" name="请假审批" isExecutable="true">
<process id="leaveApproval" name="请假分级审批" isExecutable="true">
<startEvent id="start" name="申请已提交"/>
<sequenceFlow id="flow-start-review" sourceRef="start" targetRef="managerReview"/>
<sequenceFlow id="flow-start-manager" sourceRef="start" targetRef="managerReview"/>
<userTask id="managerReview"
name="部门主管审批"
flowable:assignee="${approverId}"/>
<sequenceFlow id="flow-review-decision" sourceRef="managerReview" targetRef="decision"/>
<sequenceFlow id="flow-manager-decision" sourceRef="managerReview" targetRef="managerDecision"/>
<exclusiveGateway id="decision" name="审批结果"/>
<sequenceFlow id="flow-approved" sourceRef="decision" targetRef="approvedEnd">
<exclusiveGateway id="managerDecision" name="主管审批结果"/>
<sequenceFlow id="flow-manager-rejected" sourceRef="managerDecision" targetRef="rejectedEnd">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="flow-manager-approved" sourceRef="managerDecision" targetRef="durationDecision">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="flow-rejected" sourceRef="decision" targetRef="rejectedEnd">
<exclusiveGateway id="durationDecision" name="是否超过 24 小时"/>
<sequenceFlow id="flow-short-approved" sourceRef="durationDecision" targetRef="approvedEnd">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${durationMinutes <= 1440}]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="flow-long-oa-review" sourceRef="durationDecision" targetRef="oaReview">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${durationMinutes > 1440}]]></conditionExpression>
</sequenceFlow>
<userTask id="oaReview"
name="OA 管理员复核"
flowable:assignee="${oaAdministratorId}"/>
<sequenceFlow id="flow-oa-decision" sourceRef="oaReview" targetRef="oaDecision"/>
<exclusiveGateway id="oaDecision" name="OA 复核结果"/>
<sequenceFlow id="flow-oa-approved" sourceRef="oaDecision" targetRef="approvedEnd">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="flow-oa-rejected" sourceRef="oaDecision" targetRef="rejectedEnd">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression>
</sequenceFlow>
@@ -107,6 +107,8 @@ class ApprovalTaskServiceTest {
leaveRequestId: UUID,
applicantId: UUID,
approverId: UUID,
oaAdministratorId: UUID,
durationMinutes: Long,
): StartedProcess = error("Not used")
override fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask> = listOf(task)
@@ -131,7 +131,13 @@ class LeaveRequestServiceTest {
) = LeaveRequestService(
repository,
AuditService(auditRepository),
ApprovalRoutingRepository { _, _ -> UUID.fromString("40000000-0000-7000-8000-000000000002") },
object : ApprovalRoutingRepository {
override fun findDepartmentManager(tenantId: UUID, applicantId: UUID) =
UUID.fromString("40000000-0000-7000-8000-000000000002")
override fun findOaAdministrator(tenantId: UUID) =
UUID.fromString("40000000-0000-7000-8000-000000000003")
},
FakeWorkflowGateway(),
)
@@ -141,6 +147,8 @@ class LeaveRequestServiceTest {
leaveRequestId: UUID,
applicantId: UUID,
approverId: UUID,
oaAdministratorId: UUID,
durationMinutes: Long,
) = StartedProcess("process-$leaveRequestId", "leaveApproval:1:test")
override fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask> = emptyList()