feat: add parallel OA and HR leave review

This commit is contained in:
selfrelease
2026-07-18 08:57:45 +08:00
parent 4741fb26c8
commit e13b6c7778
17 changed files with 162 additions and 27 deletions
+2
View File
@@ -30,6 +30,7 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home
- Flowable 7.2 部门主管审批 BPMN、待办、批准和驳回 - Flowable 7.2 部门主管审批 BPMN、待办、批准和驳回
- 复杂流程安全边界:中间串行/并行任务不提前结束业务申请 - 复杂流程安全边界:中间串行/并行任务不提前结束业务申请
- 条件串行流程:不超过 24 小时由主管审批,超过 24 小时增加 OA 管理员复核 - 条件串行流程:不超过 24 小时由主管审批,超过 24 小时增加 OA 管理员复核
- 并行会签:超过 24 小时的年假由 OA 与 HR 同时复核,全部同意才通过
Flowable 开发环境自动维护 `flowable` Schema。生产环境必须设置 `FLOWABLE_SCHEMA_UPDATE=false`,并通过受控数据库变更流程管理 Flowable 表结构。 Flowable 开发环境自动维护 `flowable` Schema。生产环境必须设置 `FLOWABLE_SCHEMA_UPDATE=false`,并通过受控数据库变更流程管理 Flowable 表结构。
@@ -43,6 +44,7 @@ Flowable 开发环境自动维护 `flowable` Schema。生产环境必须设置 `
| `employee` | `Employee123!` | 普通员工 | | `employee` | `Employee123!` | 普通员工 |
| `manager` | `Manager123!` | 员工、部门主管 | | `manager` | `Manager123!` | 员工、部门主管 |
| `admin` | `Admin123!` | 员工、OA 管理员 | | `admin` | `Admin123!` | 员工、OA 管理员 |
| `hr` | `HrReviewer123!` | 员工、人力资源复核人 |
这些凭据只允许用于本地开发,生产 Realm 不得导入测试用户,也不得启用密码模式。 这些凭据只允许用于本地开发,生产 Realm 不得导入测试用户,也不得启用密码模式。
@@ -87,6 +87,16 @@ class LeaveRequestService(
if (durationMinutes > 1440 && oaAdministratorId == approverId) { if (durationMinutes > 1440 && oaAdministratorId == approverId) {
throw ApiException(HttpStatus.CONFLICT, "APPROVER_SEPARATION_REQUIRED", "长期请假的主管和 OA 复核人必须为不同人员") throw ApiException(HttpStatus.CONFLICT, "APPROVER_SEPARATION_REQUIRED", "长期请假的主管和 OA 复核人必须为不同人员")
} }
val requiresParallelAnnualReview = durationMinutes > 1440 && existing.type == LeaveType.ANNUAL
val hrReviewerId = if (requiresParallelAnnualReview) {
routingRepository.findHrReviewer(actor.tenantId)
?: throw ApiException(HttpStatus.CONFLICT, "HR_REVIEWER_NOT_FOUND", "未找到有效的人力资源复核人")
} else {
oaAdministratorId
}
if (requiresParallelAnnualReview && hrReviewerId in setOf(actor.id, approverId, oaAdministratorId)) {
throw ApiException(HttpStatus.CONFLICT, "APPROVER_SEPARATION_REQUIRED", "长期年假的主管、OA 和 HR 复核人必须为不同人员")
}
val outcome = transition( val outcome = transition(
actor = actor, actor = actor,
id = id, id = id,
@@ -105,7 +115,9 @@ class LeaveRequestService(
actor.id, actor.id,
approverId, approverId,
oaAdministratorId, oaAdministratorId,
hrReviewerId,
durationMinutes, durationMinutes,
existing.type.name,
) )
repository.attachWorkflow( repository.attachWorkflow(
actor.tenantId, actor.tenantId,
@@ -6,4 +6,6 @@ interface ApprovalRoutingRepository {
fun findDepartmentManager(tenantId: UUID, applicantId: UUID): UUID? fun findDepartmentManager(tenantId: UUID, applicantId: UUID): UUID?
fun findOaAdministrator(tenantId: UUID): UUID? fun findOaAdministrator(tenantId: UUID): UUID?
fun findHrReviewer(tenantId: UUID): UUID?
} }
@@ -41,6 +41,12 @@ class JooqApprovalRoutingRepository(
)?.get("user_id", UUID::class.java) )?.get("user_id", UUID::class.java)
override fun findOaAdministrator(tenantId: UUID): UUID? = override fun findOaAdministrator(tenantId: UUID): UUID? =
findUserByRole(tenantId, "oa_admin")
override fun findHrReviewer(tenantId: UUID): UUID? =
findUserByRole(tenantId, "hr_reviewer")
private fun findUserByRole(tenantId: UUID, roleCode: String): UUID? =
dsl.fetchOne( dsl.fetchOne(
""" """
SELECT ur.user_id SELECT ur.user_id
@@ -48,7 +54,7 @@ class JooqApprovalRoutingRepository(
JOIN authz.role role JOIN authz.role role
ON role.tenant_id = ur.tenant_id ON role.tenant_id = ur.tenant_id
AND role.id = ur.role_id AND role.id = ur.role_id
AND role.code = 'oa_admin' AND role.code = ?
AND role.status = 'ACTIVE' AND role.status = 'ACTIVE'
JOIN identity.user_account account JOIN identity.user_account account
ON account.tenant_id = ur.tenant_id ON account.tenant_id = ur.tenant_id
@@ -60,6 +66,7 @@ class JooqApprovalRoutingRepository(
ORDER BY ur.effective_from, ur.user_id ORDER BY ur.effective_from, ur.user_id
LIMIT 1 LIMIT 1
""".trimIndent(), """.trimIndent(),
roleCode,
tenantId, tenantId,
)?.get("user_id", UUID::class.java) )?.get("user_id", UUID::class.java)
} }
@@ -10,7 +10,9 @@ interface LeaveWorkflowGateway {
applicantId: UUID, applicantId: UUID,
approverId: UUID, approverId: UUID,
oaAdministratorId: UUID, oaAdministratorId: UUID,
hrReviewerId: UUID,
durationMinutes: Long, durationMinutes: Long,
leaveType: String,
): StartedProcess ): StartedProcess
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask> fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
@@ -23,7 +23,9 @@ class FlowableLeaveWorkflowGateway(
applicantId: UUID, applicantId: UUID,
approverId: UUID, approverId: UUID,
oaAdministratorId: UUID, oaAdministratorId: UUID,
hrReviewerId: UUID,
durationMinutes: Long, durationMinutes: Long,
leaveType: String,
): StartedProcess { ): StartedProcess {
val process = runtimeService.createProcessInstanceBuilder() val process = runtimeService.createProcessInstanceBuilder()
.processDefinitionKey(PROCESS_DEFINITION_KEY) .processDefinitionKey(PROCESS_DEFINITION_KEY)
@@ -35,7 +37,9 @@ class FlowableLeaveWorkflowGateway(
"applicantId" to applicantId.toString(), "applicantId" to applicantId.toString(),
"approverId" to approverId.toString(), "approverId" to approverId.toString(),
"oaAdministratorId" to oaAdministratorId.toString(), "oaAdministratorId" to oaAdministratorId.toString(),
"hrReviewerId" to hrReviewerId.toString(),
"durationMinutes" to durationMinutes, "durationMinutes" to durationMinutes,
"leaveType" to leaveType,
), ),
) )
.start() .start()
@@ -0,0 +1,47 @@
INSERT INTO organization.position (id, tenant_id, code, name, status)
VALUES (
'30000000-0000-7000-8000-000000000004',
'00000000-0000-7000-8000-000000000001',
'hr-reviewer',
'人力资源复核人',
'ACTIVE'
);
INSERT INTO identity.user_account (
id, tenant_id, keycloak_subject, username, display_name, email, status
) VALUES (
'40000000-0000-7000-8000-000000000004',
'00000000-0000-7000-8000-000000000001',
'10000000-0000-7000-8000-000000000004',
'hr',
'HR 复核人',
'hr@example.local',
'ACTIVE'
);
INSERT INTO organization.user_assignment (
id, tenant_id, user_id, department_id, position_id, is_primary
) VALUES (
'50000000-0000-7000-8000-000000000004',
'00000000-0000-7000-8000-000000000001',
'40000000-0000-7000-8000-000000000004',
'20000000-0000-7000-8000-000000000001',
'30000000-0000-7000-8000-000000000004',
TRUE
);
INSERT INTO authz.role (id, tenant_id, code, name, status)
VALUES (
'60000000-0000-7000-8000-000000000004',
'00000000-0000-7000-8000-000000000001',
'hr_reviewer',
'人力资源复核人',
'ACTIVE'
);
INSERT INTO authz.user_role (tenant_id, user_id, role_id)
VALUES (
'00000000-0000-7000-8000-000000000001',
'40000000-0000-7000-8000-000000000004',
'60000000-0000-7000-8000-000000000004'
);
@@ -0,0 +1,7 @@
INSERT INTO authz.user_role (tenant_id, user_id, role_id)
VALUES (
'00000000-0000-7000-8000-000000000001',
'40000000-0000-7000-8000-000000000004',
'60000000-0000-7000-8000-000000000001'
)
ON CONFLICT DO NOTHING;
@@ -3,45 +3,71 @@
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-manager" sourceRef="start" targetRef="managerReview"/> <sequenceFlow id="flow-start-manager" sourceRef="start" targetRef="managerReview"/>
<userTask id="managerReview" <userTask id="managerReview" name="部门主管审批" flowable:assignee="${approverId}"/>
name="部门主管审批"
flowable:assignee="${approverId}"/>
<sequenceFlow id="flow-manager-decision" sourceRef="managerReview" targetRef="managerDecision"/> <sequenceFlow id="flow-manager-decision" sourceRef="managerReview" targetRef="managerDecision"/>
<exclusiveGateway id="managerDecision" name="主管审批结果"/> <exclusiveGateway id="managerDecision" name="主管审批结果"/>
<sequenceFlow id="flow-manager-rejected" sourceRef="managerDecision" targetRef="rejectedEnd"> <sequenceFlow id="flow-manager-rejected" sourceRef="managerDecision" targetRef="rejectedTerminate">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression> <conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression>
</sequenceFlow> </sequenceFlow>
<sequenceFlow id="flow-manager-approved" sourceRef="managerDecision" targetRef="durationDecision"> <sequenceFlow id="flow-manager-approved" sourceRef="managerDecision" targetRef="routeDecision">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression> <conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression>
</sequenceFlow> </sequenceFlow>
<exclusiveGateway id="durationDecision" name="是否超过 24 小时"/> <exclusiveGateway id="routeDecision" name="按时长和类型路由"/>
<sequenceFlow id="flow-short-approved" sourceRef="durationDecision" targetRef="approvedEnd"> <sequenceFlow id="flow-short-approved" sourceRef="routeDecision" targetRef="approvedEnd">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${durationMinutes <= 1440}]]></conditionExpression> <conditionExpression xsi:type="tFormalExpression"><![CDATA[${durationMinutes <= 1440}]]></conditionExpression>
</sequenceFlow> </sequenceFlow>
<sequenceFlow id="flow-long-oa-review" sourceRef="durationDecision" targetRef="oaReview"> <sequenceFlow id="flow-long-nonannual-oa" sourceRef="routeDecision" targetRef="oaSerialReview">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${durationMinutes > 1440}]]></conditionExpression> <conditionExpression xsi:type="tFormalExpression"><![CDATA[${durationMinutes > 1440 && leaveType != 'ANNUAL'}]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="flow-long-annual-parallel" sourceRef="routeDecision" targetRef="parallelSplit">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${durationMinutes > 1440 && leaveType == 'ANNUAL'}]]></conditionExpression>
</sequenceFlow> </sequenceFlow>
<userTask id="oaReview" <userTask id="oaSerialReview" name="OA 管理员复核" flowable:assignee="${oaAdministratorId}"/>
name="OA 管理员复核" <sequenceFlow id="flow-oa-serial-decision" sourceRef="oaSerialReview" targetRef="oaSerialDecision"/>
flowable:assignee="${oaAdministratorId}"/> <exclusiveGateway id="oaSerialDecision" name="OA 复核结果"/>
<sequenceFlow id="flow-oa-decision" sourceRef="oaReview" targetRef="oaDecision"/> <sequenceFlow id="flow-oa-serial-approved" sourceRef="oaSerialDecision" targetRef="approvedEnd">
<exclusiveGateway id="oaDecision" name="OA 复核结果"/>
<sequenceFlow id="flow-oa-approved" sourceRef="oaDecision" targetRef="approvedEnd">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression> <conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression>
</sequenceFlow> </sequenceFlow>
<sequenceFlow id="flow-oa-rejected" sourceRef="oaDecision" targetRef="rejectedEnd"> <sequenceFlow id="flow-oa-serial-rejected" sourceRef="oaSerialDecision" targetRef="rejectedTerminate">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression> <conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression>
</sequenceFlow> </sequenceFlow>
<parallelGateway id="parallelSplit" name="OA 与 HR 并行复核"/>
<sequenceFlow id="flow-parallel-oa" sourceRef="parallelSplit" targetRef="oaParallelReview"/>
<sequenceFlow id="flow-parallel-hr" sourceRef="parallelSplit" targetRef="hrReview"/>
<userTask id="oaParallelReview" name="OA 并行复核" flowable:assignee="${oaAdministratorId}"/>
<sequenceFlow id="flow-oa-parallel-decision" sourceRef="oaParallelReview" targetRef="oaParallelDecision"/>
<exclusiveGateway id="oaParallelDecision" name="OA 并行复核结果"/>
<sequenceFlow id="flow-oa-parallel-approved" sourceRef="oaParallelDecision" targetRef="parallelJoin">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="flow-oa-parallel-rejected" sourceRef="oaParallelDecision" targetRef="rejectedTerminate">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression>
</sequenceFlow>
<userTask id="hrReview" name="HR 并行复核" flowable:assignee="${hrReviewerId}"/>
<sequenceFlow id="flow-hr-decision" sourceRef="hrReview" targetRef="hrDecision"/>
<exclusiveGateway id="hrDecision" name="HR 复核结果"/>
<sequenceFlow id="flow-hr-approved" sourceRef="hrDecision" targetRef="parallelJoin">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="flow-hr-rejected" sourceRef="hrDecision" targetRef="rejectedTerminate">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression>
</sequenceFlow>
<parallelGateway id="parallelJoin" name="全部复核通过"/>
<sequenceFlow id="flow-parallel-approved-end" sourceRef="parallelJoin" targetRef="approvedEnd"/>
<endEvent id="approvedEnd" name="已批准"/> <endEvent id="approvedEnd" name="已批准"/>
<endEvent id="rejectedEnd" name="已驳回"/> <endEvent id="rejectedTerminate" name="已驳回并终止其他分支">
<terminateEventDefinition/>
</endEvent>
</process> </process>
</definitions> </definitions>
@@ -108,7 +108,9 @@ class ApprovalTaskServiceTest {
applicantId: UUID, applicantId: UUID,
approverId: UUID, approverId: UUID,
oaAdministratorId: UUID, oaAdministratorId: UUID,
hrReviewerId: UUID,
durationMinutes: Long, durationMinutes: Long,
leaveType: String,
): 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)
@@ -137,6 +137,9 @@ class LeaveRequestServiceTest {
override fun findOaAdministrator(tenantId: UUID) = override fun findOaAdministrator(tenantId: UUID) =
UUID.fromString("40000000-0000-7000-8000-000000000003") UUID.fromString("40000000-0000-7000-8000-000000000003")
override fun findHrReviewer(tenantId: UUID) =
UUID.fromString("40000000-0000-7000-8000-000000000004")
}, },
FakeWorkflowGateway(), FakeWorkflowGateway(),
) )
@@ -148,7 +151,9 @@ class LeaveRequestServiceTest {
applicantId: UUID, applicantId: UUID,
approverId: UUID, approverId: UUID,
oaAdministratorId: UUID, oaAdministratorId: UUID,
hrReviewerId: UUID,
durationMinutes: Long, durationMinutes: Long,
leaveType: String,
) = 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()
+14 -1
View File
@@ -9,7 +9,8 @@
"realm": [ "realm": [
{ "name": "employee", "description": "普通员工" }, { "name": "employee", "description": "普通员工" },
{ "name": "department_manager", "description": "部门主管" }, { "name": "department_manager", "description": "部门主管" },
{ "name": "oa_admin", "description": "OA 管理员" } { "name": "oa_admin", "description": "OA 管理员" },
{ "name": "hr_reviewer", "description": "人力资源复核人" }
] ]
}, },
"clients": [ "clients": [
@@ -79,6 +80,18 @@
"attributes": { "tenant_id": ["00000000-0000-7000-8000-000000000001"] }, "attributes": { "tenant_id": ["00000000-0000-7000-8000-000000000001"] },
"credentials": [{ "type": "password", "value": "Admin123!", "temporary": false }], "credentials": [{ "type": "password", "value": "Admin123!", "temporary": false }],
"realmRoles": ["employee", "oa_admin"] "realmRoles": ["employee", "oa_admin"]
},
{
"id": "10000000-0000-7000-8000-000000000004",
"username": "hr",
"enabled": true,
"emailVerified": true,
"firstName": "人力",
"lastName": "HR",
"email": "hr@example.local",
"attributes": { "tenant_id": ["00000000-0000-7000-8000-000000000001"] },
"credentials": [{ "type": "password", "value": "HrReviewer123!", "temporary": false }],
"realmRoles": ["employee", "hr_reviewer"]
} }
] ]
} }
+1
View File
@@ -29,6 +29,7 @@
- [x] 发起、主管待办、批准、驳回、撤回和时间线 - [x] 发起、主管待办、批准、驳回、撤回和时间线
- [x] 多任务流程的中间审批事件与最终流程结束判定 - [x] 多任务流程的中间审批事件与最终流程结束判定
- [x] 按请假时长路由的主管与 OA 条件串行审批 - [x] 按请假时长路由的主管与 OA 条件串行审批
- [x] 长期年假的 OA 与 HR 并行会签及任一驳回终止
- 附件、通知、弱网恢复和幂等处理 - 附件、通知、弱网恢复和幂等处理
## M3AI 最小闭环 ## M3AI 最小闭环
+5 -4
View File
@@ -44,9 +44,10 @@ Flowable 负责任务和流程路径,`business.leave_request` 仍是请假业
└─ 同意 └─ 同意
├─ 时长 ≤ 24 小时 → APPROVED ├─ 时长 ≤ 24 小时 → APPROVED
└─ 时长 > 24 小时 └─ 时长 > 24 小时
→ OA 管理员复核 ├─ 非年假 → OA 管理员串行复核
├─ 同意 → APPROVED └─ 年假 → OA 与 HR 并行复核
└─ 驳回 → REJECTED ├─ 全部同意 → APPROVED
└─ 任一驳回 → 终止其他分支并 REJECTED
``` ```
该流程已经验证条件网关多级串行任务。后端状态同步按通用多任务语义设计,不会在第一个串行或并行任务完成时提前结束申请。 该流程已经验证条件网关多级串行、并行拆分、并行汇聚和终止事件。后端状态同步按通用多任务语义设计,不会在第一个串行或并行任务完成时提前结束申请。
+1
View File
@@ -30,6 +30,7 @@
- 已结束流程不能撤回;审批前允许申请人撤回。 - 已结束流程不能撤回;审批前允许申请人撤回。
- 请假时长不超过 24 小时,由部门主管审批后结束。 - 请假时长不超过 24 小时,由部门主管审批后结束。
- 请假时长超过 24 小时,部门主管同意后必须由 OA 管理员串行复核。 - 请假时长超过 24 小时,部门主管同意后必须由 OA 管理员串行复核。
- 超过 24 小时的年假由 OA 管理员与 HR 并行复核,全部同意才批准,任一驳回即终止流程。
- 长期请假的申请人、部门主管和 OA 复核人必须满足职责分离。 - 长期请假的申请人、部门主管和 OA 复核人必须满足职责分离。
- 所有写请求必须携带 `Idempotency-Key` - 所有写请求必须携带 `Idempotency-Key`
- 状态更新必须使用版本号防止并发覆盖。 - 状态更新必须使用版本号防止并发覆盖。
+2
View File
@@ -15,3 +15,5 @@
| AI 发起申请 | 用户确认后 | 用户确认后 | 用户确认后 | | AI 发起申请 | 用户确认后 | 用户确认后 | 用户确认后 |
后端授权模型为 RBAC + 数据范围 + ABAC。表格是产品规则,不替代服务端逐资源鉴权。 后端授权模型为 RBAC + 数据范围 + ABAC。表格是产品规则,不替代服务端逐资源鉴权。
长期年假额外包含 `hr_reviewer` 业务角色。该角色只能处理明确分配给自己的 HR 复核任务,不能因此查看全组织申请。
+2 -1
View File
@@ -7,11 +7,12 @@ backend_url="${BACKEND_URL:-http://localhost:8080}"
curl --fail --silent --show-error "${backend_url}/actuator/health" | jq . curl --fail --silent --show-error "${backend_url}/actuator/health" | jq .
for username in employee manager admin; do for username in employee manager admin hr; do
case "${username}" in case "${username}" in
employee) password='Employee123!' ;; employee) password='Employee123!' ;;
manager) password='Manager123!' ;; manager) password='Manager123!' ;;
admin) password='Admin123!' ;; admin) password='Admin123!' ;;
hr) password='HrReviewer123!' ;;
esac esac
token="$({ token="$({