diff --git a/backend/README.md b/backend/README.md index 28395dd..e0cc20a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -30,6 +30,7 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home - Flowable 7.2 部门主管审批 BPMN、待办、批准和驳回 - 复杂流程安全边界:中间串行/并行任务不提前结束业务申请 - 条件串行流程:不超过 24 小时由主管审批,超过 24 小时增加 OA 管理员复核 +- 并行会签:超过 24 小时的年假由 OA 与 HR 同时复核,全部同意才通过 Flowable 开发环境自动维护 `flowable` Schema。生产环境必须设置 `FLOWABLE_SCHEMA_UPDATE=false`,并通过受控数据库变更流程管理 Flowable 表结构。 @@ -43,6 +44,7 @@ Flowable 开发环境自动维护 `flowable` Schema。生产环境必须设置 ` | `employee` | `Employee123!` | 普通员工 | | `manager` | `Manager123!` | 员工、部门主管 | | `admin` | `Admin123!` | 员工、OA 管理员 | +| `hr` | `HrReviewer123!` | 员工、人力资源复核人 | 这些凭据只允许用于本地开发,生产 Realm 不得导入测试用户,也不得启用密码模式。 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 2fe0bab..c30897a 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 @@ -87,6 +87,16 @@ class LeaveRequestService( if (durationMinutes > 1440 && oaAdministratorId == approverId) { 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( actor = actor, id = id, @@ -105,7 +115,9 @@ class LeaveRequestService( actor.id, approverId, oaAdministratorId, + hrReviewerId, durationMinutes, + existing.type.name, ) 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 00450c0..3bcdd26 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 @@ -6,4 +6,6 @@ interface ApprovalRoutingRepository { fun findDepartmentManager(tenantId: UUID, applicantId: UUID): UUID? fun findOaAdministrator(tenantId: UUID): UUID? + + fun findHrReviewer(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 c7d1e94..1f74d92 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 @@ -41,6 +41,12 @@ class JooqApprovalRoutingRepository( )?.get("user_id", UUID::class.java) 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( """ SELECT ur.user_id @@ -48,7 +54,7 @@ class JooqApprovalRoutingRepository( JOIN authz.role role ON role.tenant_id = ur.tenant_id AND role.id = ur.role_id - AND role.code = 'oa_admin' + AND role.code = ? AND role.status = 'ACTIVE' JOIN identity.user_account account ON account.tenant_id = ur.tenant_id @@ -60,6 +66,7 @@ class JooqApprovalRoutingRepository( ORDER BY ur.effective_from, ur.user_id LIMIT 1 """.trimIndent(), + roleCode, 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 36088a7..8110a99 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 @@ -10,7 +10,9 @@ interface LeaveWorkflowGateway { applicantId: UUID, approverId: UUID, oaAdministratorId: UUID, + hrReviewerId: UUID, durationMinutes: Long, + leaveType: String, ): 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 b050ddf..4d18928 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 @@ -23,7 +23,9 @@ class FlowableLeaveWorkflowGateway( applicantId: UUID, approverId: UUID, oaAdministratorId: UUID, + hrReviewerId: UUID, durationMinutes: Long, + leaveType: String, ): StartedProcess { val process = runtimeService.createProcessInstanceBuilder() .processDefinitionKey(PROCESS_DEFINITION_KEY) @@ -35,7 +37,9 @@ class FlowableLeaveWorkflowGateway( "applicantId" to applicantId.toString(), "approverId" to approverId.toString(), "oaAdministratorId" to oaAdministratorId.toString(), + "hrReviewerId" to hrReviewerId.toString(), "durationMinutes" to durationMinutes, + "leaveType" to leaveType, ), ) .start() diff --git a/backend/boot/src/main/resources/db/migration/V7__seed_hr_reviewer.sql b/backend/boot/src/main/resources/db/migration/V7__seed_hr_reviewer.sql new file mode 100644 index 0000000..ead8018 --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V7__seed_hr_reviewer.sql @@ -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' +); diff --git a/backend/boot/src/main/resources/db/migration/V8__grant_employee_role_to_hr.sql b/backend/boot/src/main/resources/db/migration/V8__grant_employee_role_to_hr.sql new file mode 100644 index 0000000..19271a5 --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V8__grant_employee_role_to_hr.sql @@ -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; 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 37bb72c..1949ef7 100644 --- a/backend/boot/src/main/resources/processes/leave-approval.bpmn20.xml +++ b/backend/boot/src/main/resources/processes/leave-approval.bpmn20.xml @@ -3,45 +3,71 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:flowable="http://flowable.org/bpmn" targetNamespace="https://aioa.all8ai.com/processes"> - + - + - - + - + - - + + - - 1440}]]> + + 1440 && leaveType != 'ANNUAL'}]]> + + + 1440 && leaveType == 'ANNUAL'}]]> - - - - - + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + 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 4963387..6682435 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 @@ -108,7 +108,9 @@ class ApprovalTaskServiceTest { applicantId: UUID, approverId: UUID, oaAdministratorId: UUID, + hrReviewerId: UUID, durationMinutes: Long, + leaveType: String, ): 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 ca03462..d122f50 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 @@ -137,6 +137,9 @@ class LeaveRequestServiceTest { override fun findOaAdministrator(tenantId: UUID) = UUID.fromString("40000000-0000-7000-8000-000000000003") + + override fun findHrReviewer(tenantId: UUID) = + UUID.fromString("40000000-0000-7000-8000-000000000004") }, FakeWorkflowGateway(), ) @@ -148,7 +151,9 @@ class LeaveRequestServiceTest { applicantId: UUID, approverId: UUID, oaAdministratorId: UUID, + hrReviewerId: UUID, durationMinutes: Long, + leaveType: String, ) = StartedProcess("process-$leaveRequestId", "leaveApproval:1:test") override fun listAssignedTasks(assigneeId: UUID): List = emptyList() diff --git a/deploy/compose/keycloak/realm-aioa.json b/deploy/compose/keycloak/realm-aioa.json index faebc7a..cbc21f6 100644 --- a/deploy/compose/keycloak/realm-aioa.json +++ b/deploy/compose/keycloak/realm-aioa.json @@ -9,7 +9,8 @@ "realm": [ { "name": "employee", "description": "普通员工" }, { "name": "department_manager", "description": "部门主管" }, - { "name": "oa_admin", "description": "OA 管理员" } + { "name": "oa_admin", "description": "OA 管理员" }, + { "name": "hr_reviewer", "description": "人力资源复核人" } ] }, "clients": [ @@ -79,6 +80,18 @@ "attributes": { "tenant_id": ["00000000-0000-7000-8000-000000000001"] }, "credentials": [{ "type": "password", "value": "Admin123!", "temporary": false }], "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"] } ] } diff --git a/docs/engineering/roadmap.md b/docs/engineering/roadmap.md index 901bced..9561b63 100644 --- a/docs/engineering/roadmap.md +++ b/docs/engineering/roadmap.md @@ -29,6 +29,7 @@ - [x] 发起、主管待办、批准、驳回、撤回和时间线 - [x] 多任务流程的中间审批事件与最终流程结束判定 - [x] 按请假时长路由的主管与 OA 条件串行审批 +- [x] 长期年假的 OA 与 HR 并行会签及任一驳回终止 - 附件、通知、弱网恢复和幂等处理 ## M3:AI 最小闭环 diff --git a/docs/engineering/workflow-capabilities.md b/docs/engineering/workflow-capabilities.md index 098bdf0..716a468 100644 --- a/docs/engineering/workflow-capabilities.md +++ b/docs/engineering/workflow-capabilities.md @@ -44,9 +44,10 @@ Flowable 负责任务和流程路径,`business.leave_request` 仍是请假业 └─ 同意 ├─ 时长 ≤ 24 小时 → APPROVED └─ 时长 > 24 小时 - → OA 管理员复核 - ├─ 同意 → APPROVED - └─ 驳回 → REJECTED + ├─ 非年假 → OA 管理员串行复核 + └─ 年假 → OA 与 HR 并行复核 + ├─ 全部同意 → APPROVED + └─ 任一驳回 → 终止其他分支并 REJECTED ``` -该流程已经验证条件网关和多级串行任务。后端状态同步按通用多任务语义设计,不会在第一个串行或并行任务完成时提前结束申请。 +该流程已经验证条件网关、多级串行、并行拆分、并行汇聚和终止事件。后端状态同步按通用多任务语义设计,不会在第一个串行或并行任务完成时提前结束申请。 diff --git a/docs/product/mvp.md b/docs/product/mvp.md index c57e0f6..4de7c47 100644 --- a/docs/product/mvp.md +++ b/docs/product/mvp.md @@ -30,6 +30,7 @@ - 已结束流程不能撤回;审批前允许申请人撤回。 - 请假时长不超过 24 小时,由部门主管审批后结束。 - 请假时长超过 24 小时,部门主管同意后必须由 OA 管理员串行复核。 +- 超过 24 小时的年假由 OA 管理员与 HR 并行复核,全部同意才批准,任一驳回即终止流程。 - 长期请假的申请人、部门主管和 OA 复核人必须满足职责分离。 - 所有写请求必须携带 `Idempotency-Key`。 - 状态更新必须使用版本号防止并发覆盖。 diff --git a/docs/product/permission-matrix.md b/docs/product/permission-matrix.md index d00ed4b..4063b65 100644 --- a/docs/product/permission-matrix.md +++ b/docs/product/permission-matrix.md @@ -15,3 +15,5 @@ | AI 发起申请 | 用户确认后 | 用户确认后 | 用户确认后 | 后端授权模型为 RBAC + 数据范围 + ABAC。表格是产品规则,不替代服务端逐资源鉴权。 + +长期年假额外包含 `hr_reviewer` 业务角色。该角色只能处理明确分配给自己的 HR 复核任务,不能因此查看全组织申请。 diff --git a/scripts/verify-local-auth.sh b/scripts/verify-local-auth.sh index 0bd0091..c17537b 100755 --- a/scripts/verify-local-auth.sh +++ b/scripts/verify-local-auth.sh @@ -7,11 +7,12 @@ backend_url="${BACKEND_URL:-http://localhost:8080}" 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 employee) password='Employee123!' ;; manager) password='Manager123!' ;; admin) password='Admin123!' ;; + hr) password='HrReviewer123!' ;; esac token="$({