From 4741fb26c857935188f1b2b6f9875ebcc65bff67 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sat, 18 Jul 2026 08:49:02 +0800 Subject: [PATCH] feat: add conditional multi-level leave approval --- backend/README.md | 1 + .../application/LeaveRequestService.kt | 13 ++++++++ .../domain/ApprovalRoutingRepository.kt | 4 ++- .../JooqApprovalRoutingRepository.kt | 23 +++++++++++++ .../workflow/domain/LeaveWorkflowGateway.kt | 2 ++ .../FlowableLeaveWorkflowGateway.kt | 4 +++ .../processes/leave-approval.bpmn20.xml | 33 +++++++++++++++---- .../application/ApprovalTaskServiceTest.kt | 2 ++ .../application/LeaveRequestServiceTest.kt | 10 +++++- docs/engineering/roadmap.md | 1 + docs/engineering/workflow-capabilities.md | 16 ++++++++- docs/product/mvp.md | 3 ++ 12 files changed, 103 insertions(+), 9 deletions(-) diff --git a/backend/README.md b/backend/README.md index f973580..28395dd 100644 --- a/backend/README.md +++ b/backend/README.md @@ -29,6 +29,7 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home - 请假提交、撤回、状态时间线与事务内审计 - Flowable 7.2 部门主管审批 BPMN、待办、批准和驳回 - 复杂流程安全边界:中间串行/并行任务不提前结束业务申请 +- 条件串行流程:不超过 24 小时由主管审批,超过 24 小时增加 OA 管理员复核 Flowable 开发环境自动维护 `flowable` Schema。生产环境必须设置 `FLOWABLE_SCHEMA_UPDATE=false`,并通过受控数据库变更流程管理 Flowable 表结构。 diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt index 20cb11f..2fe0bab 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt @@ -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, diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/domain/ApprovalRoutingRepository.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/domain/ApprovalRoutingRepository.kt index 1149f29..00450c0 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/domain/ApprovalRoutingRepository.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/domain/ApprovalRoutingRepository.kt @@ -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? } diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/infrastructure/JooqApprovalRoutingRepository.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/infrastructure/JooqApprovalRoutingRepository.kt index e4975aa..c7d1e94 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/infrastructure/JooqApprovalRoutingRepository.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/infrastructure/JooqApprovalRoutingRepository.kt @@ -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) } diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt index 187f9f1..36088a7 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt @@ -9,6 +9,8 @@ interface LeaveWorkflowGateway { leaveRequestId: UUID, applicantId: UUID, approverId: UUID, + oaAdministratorId: UUID, + durationMinutes: Long, ): StartedProcess fun listAssignedTasks(assigneeId: UUID): List diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt index 52ca5b8..b050ddf 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt @@ -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() diff --git a/backend/boot/src/main/resources/processes/leave-approval.bpmn20.xml b/backend/boot/src/main/resources/processes/leave-approval.bpmn20.xml index d39cd3c..37bb72c 100644 --- a/backend/boot/src/main/resources/processes/leave-approval.bpmn20.xml +++ b/backend/boot/src/main/resources/processes/leave-approval.bpmn20.xml @@ -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"> - + - + - + - - + + + + + - + + + + + + + 1440}]]> + + + + + + + + + + diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/approval/application/ApprovalTaskServiceTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/approval/application/ApprovalTaskServiceTest.kt index fe7435d..4963387 100644 --- a/backend/boot/src/test/kotlin/com/all8ai/aioa/approval/application/ApprovalTaskServiceTest.kt +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/approval/application/ApprovalTaskServiceTest.kt @@ -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 = listOf(task) diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/approval/application/LeaveRequestServiceTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/approval/application/LeaveRequestServiceTest.kt index 776b611..ca03462 100644 --- a/backend/boot/src/test/kotlin/com/all8ai/aioa/approval/application/LeaveRequestServiceTest.kt +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/approval/application/LeaveRequestServiceTest.kt @@ -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 = emptyList() diff --git a/docs/engineering/roadmap.md b/docs/engineering/roadmap.md index e8d9c77..901bced 100644 --- a/docs/engineering/roadmap.md +++ b/docs/engineering/roadmap.md @@ -28,6 +28,7 @@ - [x] Flowable 部门主管审批流程发布与执行 - [x] 发起、主管待办、批准、驳回、撤回和时间线 - [x] 多任务流程的中间审批事件与最终流程结束判定 +- [x] 按请假时长路由的主管与 OA 条件串行审批 - 附件、通知、弱网恢复和幂等处理 ## M3:AI 最小闭环 diff --git a/docs/engineering/workflow-capabilities.md b/docs/engineering/workflow-capabilities.md index 2bb9f28..098bdf0 100644 --- a/docs/engineering/workflow-capabilities.md +++ b/docs/engineering/workflow-capabilities.md @@ -35,4 +35,18 @@ Flowable 负责任务和流程路径,`business.leave_request` 仍是请假业 - 驳回路径必须明确是结束流程、退回上一步还是返回申请人修改。 - 流程发布前必须覆盖通过、驳回、超时、撤回和无审批人等路径测试。 -当前仓库部署的是单主管审批定义,但后端状态同步已经按多任务流程设计,不会在第一个串行或并行任务完成时提前结束申请。 +## 当前已部署流程 + +```text +员工提交 + → 部门主管审批 + ├─ 驳回 → REJECTED + └─ 同意 + ├─ 时长 ≤ 24 小时 → APPROVED + └─ 时长 > 24 小时 + → OA 管理员复核 + ├─ 同意 → APPROVED + └─ 驳回 → REJECTED +``` + +该流程已经验证条件网关和多级串行任务。后端状态同步按通用多任务语义设计,不会在第一个串行或并行任务完成时提前结束申请。 diff --git a/docs/product/mvp.md b/docs/product/mvp.md index 49b332f..c57e0f6 100644 --- a/docs/product/mvp.md +++ b/docs/product/mvp.md @@ -28,6 +28,9 @@ - 申请人不能审批自己的申请。 - 只有当前任务处理人可以批准或驳回。 - 已结束流程不能撤回;审批前允许申请人撤回。 +- 请假时长不超过 24 小时,由部门主管审批后结束。 +- 请假时长超过 24 小时,部门主管同意后必须由 OA 管理员串行复核。 +- 长期请假的申请人、部门主管和 OA 复核人必须满足职责分离。 - 所有写请求必须携带 `Idempotency-Key`。 - 状态更新必须使用版本号防止并发覆盖。 - 流程启动后固定引用已发布的流程版本。