feat: add conditional multi-level leave approval
This commit is contained in:
@@ -29,6 +29,7 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home
|
|||||||
- 请假提交、撤回、状态时间线与事务内审计
|
- 请假提交、撤回、状态时间线与事务内审计
|
||||||
- Flowable 7.2 部门主管审批 BPMN、待办、批准和驳回
|
- Flowable 7.2 部门主管审批 BPMN、待办、批准和驳回
|
||||||
- 复杂流程安全边界:中间串行/并行任务不提前结束业务申请
|
- 复杂流程安全边界:中间串行/并行任务不提前结束业务申请
|
||||||
|
- 条件串行流程:不超过 24 小时由主管审批,超过 24 小时增加 OA 管理员复核
|
||||||
|
|
||||||
Flowable 开发环境自动维护 `flowable` Schema。生产环境必须设置 `FLOWABLE_SCHEMA_UPDATE=false`,并通过受控数据库变更流程管理 Flowable 表结构。
|
Flowable 开发环境自动维护 `flowable` Schema。生产环境必须设置 `FLOWABLE_SCHEMA_UPDATE=false`,并通过受控数据库变更流程管理 Flowable 表结构。
|
||||||
|
|
||||||
|
|||||||
+13
@@ -20,6 +20,7 @@ import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
|||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.Duration
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -71,11 +72,21 @@ class LeaveRequestService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
|
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)
|
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
|
||||||
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
|
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
|
||||||
if (approverId == actor.id) {
|
if (approverId == actor.id) {
|
||||||
throw ApiException(HttpStatus.CONFLICT, "SELF_APPROVAL_NOT_ALLOWED", "申请人不能审批自己的申请")
|
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(
|
val outcome = transition(
|
||||||
actor = actor,
|
actor = actor,
|
||||||
id = id,
|
id = id,
|
||||||
@@ -93,6 +104,8 @@ class LeaveRequestService(
|
|||||||
outcome.leaveRequest.id,
|
outcome.leaveRequest.id,
|
||||||
actor.id,
|
actor.id,
|
||||||
approverId,
|
approverId,
|
||||||
|
oaAdministratorId,
|
||||||
|
durationMinutes,
|
||||||
)
|
)
|
||||||
repository.attachWorkflow(
|
repository.attachWorkflow(
|
||||||
actor.tenantId,
|
actor.tenantId,
|
||||||
|
|||||||
+3
-1
@@ -2,6 +2,8 @@ package com.all8ai.aioa.approval.domain
|
|||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
fun interface ApprovalRoutingRepository {
|
interface ApprovalRoutingRepository {
|
||||||
fun findDepartmentManager(tenantId: UUID, applicantId: UUID): UUID?
|
fun findDepartmentManager(tenantId: UUID, applicantId: UUID): UUID?
|
||||||
|
|
||||||
|
fun findOaAdministrator(tenantId: UUID): UUID?
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -39,4 +39,27 @@ class JooqApprovalRoutingRepository(
|
|||||||
tenantId,
|
tenantId,
|
||||||
applicantId,
|
applicantId,
|
||||||
)?.get("user_id", UUID::class.java)
|
)?.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,
|
leaveRequestId: UUID,
|
||||||
applicantId: UUID,
|
applicantId: UUID,
|
||||||
approverId: UUID,
|
approverId: UUID,
|
||||||
|
oaAdministratorId: UUID,
|
||||||
|
durationMinutes: Long,
|
||||||
): StartedProcess
|
): StartedProcess
|
||||||
|
|
||||||
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
|
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
|
||||||
|
|||||||
+4
@@ -22,6 +22,8 @@ class FlowableLeaveWorkflowGateway(
|
|||||||
leaveRequestId: UUID,
|
leaveRequestId: UUID,
|
||||||
applicantId: UUID,
|
applicantId: UUID,
|
||||||
approverId: UUID,
|
approverId: UUID,
|
||||||
|
oaAdministratorId: UUID,
|
||||||
|
durationMinutes: Long,
|
||||||
): StartedProcess {
|
): StartedProcess {
|
||||||
val process = runtimeService.createProcessInstanceBuilder()
|
val process = runtimeService.createProcessInstanceBuilder()
|
||||||
.processDefinitionKey(PROCESS_DEFINITION_KEY)
|
.processDefinitionKey(PROCESS_DEFINITION_KEY)
|
||||||
@@ -32,6 +34,8 @@ class FlowableLeaveWorkflowGateway(
|
|||||||
"businessId" to leaveRequestId.toString(),
|
"businessId" to leaveRequestId.toString(),
|
||||||
"applicantId" to applicantId.toString(),
|
"applicantId" to applicantId.toString(),
|
||||||
"approverId" to approverId.toString(),
|
"approverId" to approverId.toString(),
|
||||||
|
"oaAdministratorId" to oaAdministratorId.toString(),
|
||||||
|
"durationMinutes" to durationMinutes,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.start()
|
.start()
|
||||||
|
|||||||
@@ -3,20 +3,41 @@
|
|||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xmlns:flowable="http://flowable.org/bpmn"
|
xmlns:flowable="http://flowable.org/bpmn"
|
||||||
targetNamespace="https://aioa.all8ai.com/processes">
|
targetNamespace="https://aioa.all8ai.com/processes">
|
||||||
<process id="leaveApproval" name="请假审批" isExecutable="true">
|
<process id="leaveApproval" name="请假分级审批" isExecutable="true">
|
||||||
<startEvent id="start" name="申请已提交"/>
|
<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"
|
<userTask id="managerReview"
|
||||||
name="部门主管审批"
|
name="部门主管审批"
|
||||||
flowable:assignee="${approverId}"/>
|
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="审批结果"/>
|
<exclusiveGateway id="managerDecision" name="主管审批结果"/>
|
||||||
<sequenceFlow id="flow-approved" sourceRef="decision" targetRef="approvedEnd">
|
<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>
|
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression>
|
||||||
</sequenceFlow>
|
</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>
|
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression>
|
||||||
</sequenceFlow>
|
</sequenceFlow>
|
||||||
|
|
||||||
|
|||||||
+2
@@ -107,6 +107,8 @@ class ApprovalTaskServiceTest {
|
|||||||
leaveRequestId: UUID,
|
leaveRequestId: UUID,
|
||||||
applicantId: UUID,
|
applicantId: UUID,
|
||||||
approverId: UUID,
|
approverId: UUID,
|
||||||
|
oaAdministratorId: UUID,
|
||||||
|
durationMinutes: Long,
|
||||||
): StartedProcess = error("Not used")
|
): StartedProcess = error("Not used")
|
||||||
|
|
||||||
override fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask> = listOf(task)
|
override fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask> = listOf(task)
|
||||||
|
|||||||
+9
-1
@@ -131,7 +131,13 @@ class LeaveRequestServiceTest {
|
|||||||
) = LeaveRequestService(
|
) = LeaveRequestService(
|
||||||
repository,
|
repository,
|
||||||
AuditService(auditRepository),
|
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(),
|
FakeWorkflowGateway(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -141,6 +147,8 @@ class LeaveRequestServiceTest {
|
|||||||
leaveRequestId: UUID,
|
leaveRequestId: UUID,
|
||||||
applicantId: UUID,
|
applicantId: UUID,
|
||||||
approverId: UUID,
|
approverId: UUID,
|
||||||
|
oaAdministratorId: UUID,
|
||||||
|
durationMinutes: Long,
|
||||||
) = StartedProcess("process-$leaveRequestId", "leaveApproval:1:test")
|
) = StartedProcess("process-$leaveRequestId", "leaveApproval:1:test")
|
||||||
|
|
||||||
override fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask> = emptyList()
|
override fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask> = emptyList()
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
- [x] Flowable 部门主管审批流程发布与执行
|
- [x] Flowable 部门主管审批流程发布与执行
|
||||||
- [x] 发起、主管待办、批准、驳回、撤回和时间线
|
- [x] 发起、主管待办、批准、驳回、撤回和时间线
|
||||||
- [x] 多任务流程的中间审批事件与最终流程结束判定
|
- [x] 多任务流程的中间审批事件与最终流程结束判定
|
||||||
|
- [x] 按请假时长路由的主管与 OA 条件串行审批
|
||||||
- 附件、通知、弱网恢复和幂等处理
|
- 附件、通知、弱网恢复和幂等处理
|
||||||
|
|
||||||
## M3:AI 最小闭环
|
## M3:AI 最小闭环
|
||||||
|
|||||||
@@ -35,4 +35,18 @@ Flowable 负责任务和流程路径,`business.leave_request` 仍是请假业
|
|||||||
- 驳回路径必须明确是结束流程、退回上一步还是返回申请人修改。
|
- 驳回路径必须明确是结束流程、退回上一步还是返回申请人修改。
|
||||||
- 流程发布前必须覆盖通过、驳回、超时、撤回和无审批人等路径测试。
|
- 流程发布前必须覆盖通过、驳回、超时、撤回和无审批人等路径测试。
|
||||||
|
|
||||||
当前仓库部署的是单主管审批定义,但后端状态同步已经按多任务流程设计,不会在第一个串行或并行任务完成时提前结束申请。
|
## 当前已部署流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
员工提交
|
||||||
|
→ 部门主管审批
|
||||||
|
├─ 驳回 → REJECTED
|
||||||
|
└─ 同意
|
||||||
|
├─ 时长 ≤ 24 小时 → APPROVED
|
||||||
|
└─ 时长 > 24 小时
|
||||||
|
→ OA 管理员复核
|
||||||
|
├─ 同意 → APPROVED
|
||||||
|
└─ 驳回 → REJECTED
|
||||||
|
```
|
||||||
|
|
||||||
|
该流程已经验证条件网关和多级串行任务。后端状态同步按通用多任务语义设计,不会在第一个串行或并行任务完成时提前结束申请。
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
- 申请人不能审批自己的申请。
|
- 申请人不能审批自己的申请。
|
||||||
- 只有当前任务处理人可以批准或驳回。
|
- 只有当前任务处理人可以批准或驳回。
|
||||||
- 已结束流程不能撤回;审批前允许申请人撤回。
|
- 已结束流程不能撤回;审批前允许申请人撤回。
|
||||||
|
- 请假时长不超过 24 小时,由部门主管审批后结束。
|
||||||
|
- 请假时长超过 24 小时,部门主管同意后必须由 OA 管理员串行复核。
|
||||||
|
- 长期请假的申请人、部门主管和 OA 复核人必须满足职责分离。
|
||||||
- 所有写请求必须携带 `Idempotency-Key`。
|
- 所有写请求必须携带 `Idempotency-Key`。
|
||||||
- 状态更新必须使用版本号防止并发覆盖。
|
- 状态更新必须使用版本号防止并发覆盖。
|
||||||
- 流程启动后固定引用已发布的流程版本。
|
- 流程启动后固定引用已发布的流程版本。
|
||||||
|
|||||||
Reference in New Issue
Block a user