feat: integrate Flowable manager approval workflow
This commit is contained in:
@@ -27,6 +27,9 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home
|
||||
- 请假草稿创建、修改、本人查询和列表
|
||||
- UUIDv7、写入幂等、租户隔离和乐观锁冲突处理
|
||||
- 请假提交、撤回、状态时间线与事务内审计
|
||||
- Flowable 7.2 部门主管审批 BPMN、待办、批准和驳回
|
||||
|
||||
Flowable 开发环境自动维护 `flowable` Schema。生产环境必须设置 `FLOWABLE_SCHEMA_UPDATE=false`,并通过受控数据库变更流程管理 Flowable 表结构。
|
||||
|
||||
## 开发身份
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ dependencies {
|
||||
implementation("org.flywaydb:flyway-core")
|
||||
implementation("org.flywaydb:flyway-database-postgresql")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
implementation("org.flowable:flowable-spring-boot-starter-process:7.2.0")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.all8ai.aioa.approval.api
|
||||
|
||||
import com.all8ai.aioa.approval.application.ApprovalTask
|
||||
import com.all8ai.aioa.approval.application.ApprovalTaskService
|
||||
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||
import jakarta.validation.Valid
|
||||
import jakarta.validation.constraints.PositiveOrZero
|
||||
import jakarta.validation.constraints.Size
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestHeader
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.time.Instant
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/approval-tasks")
|
||||
class ApprovalTaskController(
|
||||
private val currentUserService: CurrentUserService,
|
||||
private val approvalTaskService: ApprovalTaskService,
|
||||
) {
|
||||
@GetMapping
|
||||
fun listAssigned(@AuthenticationPrincipal jwt: Jwt): List<ApprovalTaskResponse> = approvalTaskService
|
||||
.listAssigned(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")))
|
||||
.map(ApprovalTask::toResponse)
|
||||
|
||||
@PostMapping("/{taskId}/approve")
|
||||
fun approve(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@PathVariable taskId: String,
|
||||
@RequestHeader("Idempotency-Key") idempotencyKey: String,
|
||||
@Valid @RequestBody request: ApprovalDecisionRequest,
|
||||
): LeaveRequestResponse = approvalTaskService.approve(
|
||||
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")),
|
||||
taskId,
|
||||
idempotencyKey,
|
||||
request.version,
|
||||
request.comment,
|
||||
).toResponseModel()
|
||||
|
||||
@PostMapping("/{taskId}/reject")
|
||||
fun reject(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@PathVariable taskId: String,
|
||||
@RequestHeader("Idempotency-Key") idempotencyKey: String,
|
||||
@Valid @RequestBody request: ApprovalDecisionRequest,
|
||||
): LeaveRequestResponse = approvalTaskService.reject(
|
||||
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")),
|
||||
taskId,
|
||||
idempotencyKey,
|
||||
request.version,
|
||||
request.comment,
|
||||
).toResponseModel()
|
||||
}
|
||||
|
||||
data class ApprovalDecisionRequest(
|
||||
@field:PositiveOrZero
|
||||
val version: Long,
|
||||
@field:Size(max = 1000)
|
||||
val comment: String? = null,
|
||||
)
|
||||
|
||||
data class ApprovalTaskResponse(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val createdAt: Instant,
|
||||
val leaveRequest: LeaveRequestResponse,
|
||||
)
|
||||
|
||||
private fun ApprovalTask.toResponse() = ApprovalTaskResponse(
|
||||
id = id,
|
||||
name = name,
|
||||
createdAt = createdAt,
|
||||
leaveRequest = leaveRequest.toResponseModel(),
|
||||
)
|
||||
+11
-7
@@ -40,7 +40,7 @@ class LeaveRequestController(
|
||||
@Valid @RequestBody request: SaveDraftRequest,
|
||||
): LeaveRequestResponse = leaveRequestService
|
||||
.createDraft(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), idempotencyKey, request.toCommand())
|
||||
.toResponse()
|
||||
.toResponseModel()
|
||||
|
||||
@PutMapping("/{id}")
|
||||
fun updateDraft(
|
||||
@@ -49,7 +49,7 @@ class LeaveRequestController(
|
||||
@Valid @RequestBody request: SaveDraftRequest,
|
||||
): LeaveRequestResponse = leaveRequestService
|
||||
.updateDraft(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id, request.toCommand())
|
||||
.toResponse()
|
||||
.toResponseModel()
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun getOwn(
|
||||
@@ -57,12 +57,12 @@ class LeaveRequestController(
|
||||
@PathVariable id: UUID,
|
||||
): LeaveRequestResponse = leaveRequestService
|
||||
.getOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id)
|
||||
.toResponse()
|
||||
.toResponseModel()
|
||||
|
||||
@GetMapping
|
||||
fun listOwn(@AuthenticationPrincipal jwt: Jwt): List<LeaveRequestResponse> = leaveRequestService
|
||||
.listOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")))
|
||||
.map(LeaveRequest::toResponse)
|
||||
.map(LeaveRequest::toResponseModel)
|
||||
|
||||
@PostMapping("/{id}/submit")
|
||||
fun submit(
|
||||
@@ -76,7 +76,7 @@ class LeaveRequestController(
|
||||
id,
|
||||
idempotencyKey,
|
||||
request.version,
|
||||
).toResponse()
|
||||
).toResponseModel()
|
||||
|
||||
@PostMapping("/{id}/withdraw")
|
||||
fun withdraw(
|
||||
@@ -90,7 +90,7 @@ class LeaveRequestController(
|
||||
id,
|
||||
idempotencyKey,
|
||||
request.version,
|
||||
).toResponse()
|
||||
).toResponseModel()
|
||||
|
||||
@GetMapping("/{id}/timeline")
|
||||
fun timeline(
|
||||
@@ -125,6 +125,8 @@ data class LeaveRequestResponse(
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
val version: Long,
|
||||
val processInstanceId: String?,
|
||||
val processDefinitionId: String?,
|
||||
)
|
||||
|
||||
data class TransitionRequest(
|
||||
@@ -142,7 +144,7 @@ data class LeaveRequestEventResponse(
|
||||
val occurredAt: Instant,
|
||||
)
|
||||
|
||||
private fun LeaveRequest.toResponse() = LeaveRequestResponse(
|
||||
internal fun LeaveRequest.toResponseModel() = LeaveRequestResponse(
|
||||
id = id,
|
||||
applicantId = applicantId,
|
||||
type = type,
|
||||
@@ -153,6 +155,8 @@ private fun LeaveRequest.toResponse() = LeaveRequestResponse(
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt,
|
||||
version = version,
|
||||
processInstanceId = processInstanceId,
|
||||
processDefinitionId = processDefinitionId,
|
||||
)
|
||||
|
||||
private fun LeaveRequestEvent.toResponse() = LeaveRequestEventResponse(
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.all8ai.aioa.approval.application
|
||||
|
||||
import com.all8ai.aioa.approval.domain.LeaveRequest
|
||||
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
||||
import com.all8ai.aioa.approval.domain.LeaveStatus
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.shared.id.UuidV7
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import com.all8ai.aioa.shared.web.TraceIdFilter
|
||||
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
|
||||
@Service
|
||||
class ApprovalTaskService(
|
||||
private val workflowGateway: LeaveWorkflowGateway,
|
||||
private val leaveRequestRepository: LeaveRequestRepository,
|
||||
private val auditService: AuditService,
|
||||
) {
|
||||
fun listAssigned(actor: CurrentUser): List<ApprovalTask> =
|
||||
workflowGateway.listAssignedTasks(actor.id).mapNotNull { task ->
|
||||
val request = leaveRequestRepository.findById(actor.tenantId, task.leaveRequestId)
|
||||
?: return@mapNotNull null
|
||||
if (request.status != LeaveStatus.PENDING) return@mapNotNull null
|
||||
ApprovalTask(task.id, task.name, task.createdAt, request)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun approve(
|
||||
actor: CurrentUser,
|
||||
taskId: String,
|
||||
idempotencyKey: String,
|
||||
expectedVersion: Long,
|
||||
comment: String?,
|
||||
): LeaveRequest = decide(actor, taskId, idempotencyKey, expectedVersion, comment, approved = true)
|
||||
|
||||
@Transactional
|
||||
fun reject(
|
||||
actor: CurrentUser,
|
||||
taskId: String,
|
||||
idempotencyKey: String,
|
||||
expectedVersion: Long,
|
||||
comment: String?,
|
||||
): LeaveRequest = decide(actor, taskId, idempotencyKey, expectedVersion, comment, approved = false)
|
||||
|
||||
private fun decide(
|
||||
actor: CurrentUser,
|
||||
taskId: String,
|
||||
idempotencyKey: String,
|
||||
expectedVersion: Long,
|
||||
comment: String?,
|
||||
approved: Boolean,
|
||||
): LeaveRequest {
|
||||
validate(idempotencyKey, expectedVersion, comment)
|
||||
val task = workflowGateway.resolveTask(taskId)
|
||||
?: throw ApiException(HttpStatus.NOT_FOUND, "APPROVAL_TASK_NOT_FOUND", "审批任务不存在")
|
||||
if (task.assigneeId != actor.id) {
|
||||
throw ApiException(HttpStatus.NOT_FOUND, "APPROVAL_TASK_NOT_FOUND", "审批任务不存在")
|
||||
}
|
||||
val request = leaveRequestRepository.findById(actor.tenantId, task.leaveRequestId)
|
||||
?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在")
|
||||
if (request.applicantId == actor.id) {
|
||||
throw ApiException(HttpStatus.CONFLICT, "SELF_APPROVAL_NOT_ALLOWED", "申请人不能审批自己的申请")
|
||||
}
|
||||
|
||||
val operation = if (approved) "LEAVE_APPROVE" else "LEAVE_REJECT"
|
||||
val action = if (approved) "LEAVE_REQUEST_APPROVED" else "LEAVE_REQUEST_REJECTED"
|
||||
val targetStatus = if (approved) LeaveStatus.APPROVED else LeaveStatus.REJECTED
|
||||
val fingerprint = fingerprint(operation, taskId, expectedVersion, comment)
|
||||
val claim = leaveRequestRepository.claimOperation(
|
||||
UuidV7.generate(), actor.tenantId, actor.id, operation,
|
||||
idempotencyKey, fingerprint, request.id,
|
||||
)
|
||||
if (claim.fingerprint != fingerprint || claim.resourceId != request.id) {
|
||||
throw ApiException(HttpStatus.CONFLICT, "IDEMPOTENCY_KEY_REUSED", "该幂等键已用于不同的操作参数")
|
||||
}
|
||||
if (!claim.claimed) {
|
||||
return leaveRequestRepository.findById(actor.tenantId, request.id)!!
|
||||
}
|
||||
if (task.completed) {
|
||||
throw ApiException(HttpStatus.CONFLICT, "APPROVAL_TASK_COMPLETED", "审批任务已处理")
|
||||
}
|
||||
|
||||
val transitioned = leaveRequestRepository.transitionForApprover(
|
||||
tenantId = actor.tenantId,
|
||||
actorId = actor.id,
|
||||
id = request.id,
|
||||
expectedVersion = expectedVersion,
|
||||
toStatus = targetStatus,
|
||||
eventId = UuidV7.generate(),
|
||||
eventType = action,
|
||||
traceId = MDC.get(TraceIdFilter.MDC_TRACE_ID) ?: "unknown",
|
||||
) ?: transitionFailure(actor, request.id, expectedVersion)
|
||||
|
||||
workflowGateway.completeTask(taskId, approved, comment)
|
||||
auditService.recordSuccess(
|
||||
actor = actor,
|
||||
action = action,
|
||||
resourceType = "LEAVE_REQUEST",
|
||||
resourceId = request.id.toString(),
|
||||
idempotencyKey = idempotencyKey,
|
||||
details = mapOf(
|
||||
"taskId" to taskId,
|
||||
"toStatus" to targetStatus.name,
|
||||
"version" to transitioned.version,
|
||||
"comment" to comment?.trim(),
|
||||
),
|
||||
)
|
||||
return transitioned
|
||||
}
|
||||
|
||||
private fun transitionFailure(actor: CurrentUser, id: java.util.UUID, expectedVersion: Long): Nothing {
|
||||
val existing = leaveRequestRepository.findById(actor.tenantId, id)
|
||||
?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在")
|
||||
if (existing.status != LeaveStatus.PENDING) {
|
||||
throw ApiException(HttpStatus.CONFLICT, "LEAVE_STATUS_INVALID", "当前申请状态不允许审批")
|
||||
}
|
||||
if (existing.version != expectedVersion) {
|
||||
throw ApiException(HttpStatus.CONFLICT, "VERSION_CONFLICT", "申请已被其他操作更新,请刷新后重试")
|
||||
}
|
||||
throw ApiException(HttpStatus.CONFLICT, "LEAVE_TRANSITION_CONFLICT", "审批状态转换失败")
|
||||
}
|
||||
|
||||
private fun validate(idempotencyKey: String, version: Long, comment: String?) {
|
||||
if (idempotencyKey.length !in 16..128 || !idempotencyKey.matches(Regex("[A-Za-z0-9._:-]+"))) {
|
||||
throw ApiException(HttpStatus.BAD_REQUEST, "IDEMPOTENCY_KEY_INVALID", "幂等键格式无效")
|
||||
}
|
||||
if (version < 0) throw ApiException(HttpStatus.BAD_REQUEST, "VERSION_INVALID", "版本号不能小于零")
|
||||
if (comment != null && comment.trim().length > 1000) {
|
||||
throw ApiException(HttpStatus.BAD_REQUEST, "APPROVAL_COMMENT_INVALID", "审批意见不能超过 1000 个字符")
|
||||
}
|
||||
}
|
||||
|
||||
private fun fingerprint(operation: String, taskId: String, version: Long, comment: String?): String {
|
||||
val canonical = "$operation|$taskId|$version|${comment?.trim().orEmpty()}"
|
||||
return MessageDigest.getInstance("SHA-256")
|
||||
.digest(canonical.toByteArray(StandardCharsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
}
|
||||
|
||||
data class ApprovalTask(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val createdAt: java.time.Instant,
|
||||
val leaveRequest: LeaveRequest,
|
||||
)
|
||||
+47
-7
@@ -4,6 +4,7 @@ import com.all8ai.aioa.approval.domain.DraftContent
|
||||
import com.all8ai.aioa.approval.domain.LeaveRequest
|
||||
import com.all8ai.aioa.approval.domain.LeaveRequestEvent
|
||||
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
||||
import com.all8ai.aioa.approval.domain.ApprovalRoutingRepository
|
||||
import com.all8ai.aioa.approval.domain.LeaveStatus
|
||||
import com.all8ai.aioa.approval.domain.LeaveType
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
@@ -15,6 +16,7 @@ import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.slf4j.MDC
|
||||
import com.all8ai.aioa.shared.web.TraceIdFilter
|
||||
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
@@ -24,6 +26,8 @@ import java.util.UUID
|
||||
class LeaveRequestService(
|
||||
private val repository: LeaveRequestRepository,
|
||||
private val auditService: AuditService,
|
||||
private val routingRepository: ApprovalRoutingRepository,
|
||||
private val workflowGateway: LeaveWorkflowGateway,
|
||||
) {
|
||||
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
|
||||
validateIdempotencyKey(idempotencyKey)
|
||||
@@ -66,8 +70,13 @@ class LeaveRequestService(
|
||||
fun listOwn(actor: CurrentUser): List<LeaveRequest> = repository.listOwn(actor.tenantId, actor.id, 100)
|
||||
|
||||
@Transactional
|
||||
fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest =
|
||||
transition(
|
||||
fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
|
||||
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 outcome = transition(
|
||||
actor = actor,
|
||||
id = id,
|
||||
idempotencyKey = idempotencyKey,
|
||||
@@ -77,10 +86,29 @@ class LeaveRequestService(
|
||||
fromStatus = LeaveStatus.DRAFT,
|
||||
toStatus = LeaveStatus.PENDING,
|
||||
)
|
||||
if (outcome.replayed) return outcome.leaveRequest
|
||||
|
||||
val process = workflowGateway.startLeaveApproval(
|
||||
actor.tenantId,
|
||||
outcome.leaveRequest.id,
|
||||
actor.id,
|
||||
approverId,
|
||||
)
|
||||
repository.attachWorkflow(
|
||||
actor.tenantId,
|
||||
outcome.leaveRequest.id,
|
||||
process.processInstanceId,
|
||||
process.processDefinitionId,
|
||||
)
|
||||
return outcome.leaveRequest.copy(
|
||||
processInstanceId = process.processInstanceId,
|
||||
processDefinitionId = process.processDefinitionId,
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun withdraw(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest =
|
||||
transition(
|
||||
fun withdraw(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
|
||||
val outcome = transition(
|
||||
actor = actor,
|
||||
id = id,
|
||||
idempotencyKey = idempotencyKey,
|
||||
@@ -90,6 +118,13 @@ class LeaveRequestService(
|
||||
fromStatus = LeaveStatus.PENDING,
|
||||
toStatus = LeaveStatus.WITHDRAWN,
|
||||
)
|
||||
if (!outcome.replayed) {
|
||||
outcome.leaveRequest.processInstanceId?.let {
|
||||
workflowGateway.cancelProcess(it, "Applicant withdrew leave request")
|
||||
}
|
||||
}
|
||||
return outcome.leaveRequest
|
||||
}
|
||||
|
||||
fun timeline(actor: CurrentUser, id: UUID): List<LeaveRequestEvent> {
|
||||
getOwn(actor, id)
|
||||
@@ -105,7 +140,7 @@ class LeaveRequestService(
|
||||
action: String,
|
||||
fromStatus: LeaveStatus,
|
||||
toStatus: LeaveStatus,
|
||||
): LeaveRequest {
|
||||
): TransitionOutcome {
|
||||
validateIdempotencyKey(idempotencyKey)
|
||||
if (expectedVersion < 0) {
|
||||
throw ApiException(HttpStatus.BAD_REQUEST, "VERSION_INVALID", "版本号不能小于零")
|
||||
@@ -119,7 +154,7 @@ class LeaveRequestService(
|
||||
throw ApiException(HttpStatus.CONFLICT, "IDEMPOTENCY_KEY_REUSED", "该幂等键已用于不同的操作参数")
|
||||
}
|
||||
if (!claim.claimed) {
|
||||
return getOwn(actor, id)
|
||||
return TransitionOutcome(getOwn(actor, id), replayed = true)
|
||||
}
|
||||
|
||||
val traceId = MDC.get(TraceIdFilter.MDC_TRACE_ID) ?: "unknown"
|
||||
@@ -140,7 +175,7 @@ class LeaveRequestService(
|
||||
"version" to transitioned.version,
|
||||
),
|
||||
)
|
||||
return transitioned
|
||||
return TransitionOutcome(transitioned, replayed = false)
|
||||
}
|
||||
|
||||
private fun transitionFailure(
|
||||
@@ -192,6 +227,11 @@ class LeaveRequestService(
|
||||
}
|
||||
}
|
||||
|
||||
private data class TransitionOutcome(
|
||||
val leaveRequest: LeaveRequest,
|
||||
val replayed: Boolean,
|
||||
)
|
||||
|
||||
data class SaveDraftCommand(
|
||||
val type: LeaveType,
|
||||
val startsAt: Instant,
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.all8ai.aioa.approval.domain
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
fun interface ApprovalRoutingRepository {
|
||||
fun findDepartmentManager(tenantId: UUID, applicantId: UUID): UUID?
|
||||
}
|
||||
@@ -29,6 +29,8 @@ data class LeaveRequest(
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
val version: Long,
|
||||
val processInstanceId: String? = null,
|
||||
val processDefinitionId: String? = null,
|
||||
)
|
||||
|
||||
data class DraftContent(
|
||||
@@ -83,6 +85,26 @@ interface LeaveRequestRepository {
|
||||
): LeaveRequest?
|
||||
|
||||
fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List<LeaveRequestEvent>
|
||||
|
||||
fun attachWorkflow(
|
||||
tenantId: UUID,
|
||||
leaveRequestId: UUID,
|
||||
processInstanceId: String,
|
||||
processDefinitionId: String,
|
||||
)
|
||||
|
||||
fun findById(tenantId: UUID, id: UUID): LeaveRequest?
|
||||
|
||||
fun transitionForApprover(
|
||||
tenantId: UUID,
|
||||
actorId: UUID,
|
||||
id: UUID,
|
||||
expectedVersion: Long,
|
||||
toStatus: LeaveStatus,
|
||||
eventId: UUID,
|
||||
eventType: String,
|
||||
traceId: String,
|
||||
): LeaveRequest?
|
||||
}
|
||||
|
||||
data class IdempotentDraft(
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.all8ai.aioa.approval.infrastructure
|
||||
|
||||
import com.all8ai.aioa.approval.domain.ApprovalRoutingRepository
|
||||
import org.jooq.DSLContext
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class JooqApprovalRoutingRepository(
|
||||
private val dsl: DSLContext,
|
||||
) : ApprovalRoutingRepository {
|
||||
override fun findDepartmentManager(tenantId: UUID, applicantId: UUID): UUID? =
|
||||
dsl.fetchOne(
|
||||
"""
|
||||
SELECT manager.user_id
|
||||
FROM organization.user_assignment applicant
|
||||
JOIN organization.user_assignment manager
|
||||
ON manager.tenant_id = applicant.tenant_id
|
||||
AND manager.department_id = applicant.department_id
|
||||
AND manager.effective_from <= CURRENT_TIMESTAMP
|
||||
AND (manager.effective_until IS NULL OR manager.effective_until > CURRENT_TIMESTAMP)
|
||||
JOIN organization.position position
|
||||
ON position.tenant_id = manager.tenant_id
|
||||
AND position.id = manager.position_id
|
||||
AND position.code = 'manager'
|
||||
AND position.status = 'ACTIVE'
|
||||
JOIN identity.user_account account
|
||||
ON account.tenant_id = manager.tenant_id
|
||||
AND account.id = manager.user_id
|
||||
AND account.status = 'ACTIVE'
|
||||
WHERE applicant.tenant_id = ?
|
||||
AND applicant.user_id = ?
|
||||
AND applicant.is_primary = TRUE
|
||||
AND applicant.effective_from <= CURRENT_TIMESTAMP
|
||||
AND (applicant.effective_until IS NULL OR applicant.effective_until > CURRENT_TIMESTAMP)
|
||||
ORDER BY manager.is_primary DESC, manager.created_at
|
||||
LIMIT 1
|
||||
""".trimIndent(),
|
||||
tenantId,
|
||||
applicantId,
|
||||
)?.get("user_id", UUID::class.java)
|
||||
}
|
||||
+72
@@ -221,6 +221,76 @@ class JooqLeaveRequestRepository(
|
||||
)
|
||||
}
|
||||
|
||||
override fun attachWorkflow(
|
||||
tenantId: UUID,
|
||||
leaveRequestId: UUID,
|
||||
processInstanceId: String,
|
||||
processDefinitionId: String,
|
||||
) {
|
||||
val updated = dsl.execute(
|
||||
"""
|
||||
UPDATE business.leave_request
|
||||
SET process_instance_id = ?, process_definition_id = ?
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'
|
||||
AND process_instance_id IS NULL
|
||||
""".trimIndent(),
|
||||
processInstanceId,
|
||||
processDefinitionId,
|
||||
tenantId,
|
||||
leaveRequestId,
|
||||
)
|
||||
check(updated == 1) { "Unable to attach workflow to leave request $leaveRequestId" }
|
||||
}
|
||||
|
||||
override fun findById(tenantId: UUID, id: UUID): LeaveRequest? =
|
||||
dsl.fetchOne(
|
||||
"SELECT * FROM business.leave_request WHERE tenant_id = ? AND id = ?",
|
||||
tenantId,
|
||||
id,
|
||||
)?.let(::map)
|
||||
|
||||
override fun transitionForApprover(
|
||||
tenantId: UUID,
|
||||
actorId: UUID,
|
||||
id: UUID,
|
||||
expectedVersion: Long,
|
||||
toStatus: LeaveStatus,
|
||||
eventId: UUID,
|
||||
eventType: String,
|
||||
traceId: String,
|
||||
): LeaveRequest? {
|
||||
val transitioned = dsl.fetchOne(
|
||||
"""
|
||||
UPDATE business.leave_request
|
||||
SET status = ?, completed_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'PENDING' AND version = ?
|
||||
RETURNING *
|
||||
""".trimIndent(),
|
||||
toStatus.name,
|
||||
tenantId,
|
||||
id,
|
||||
expectedVersion,
|
||||
)?.let(::map) ?: return null
|
||||
|
||||
dsl.execute(
|
||||
"""
|
||||
INSERT INTO business.leave_request_event (
|
||||
id, tenant_id, leave_request_id, actor_id, event_type,
|
||||
from_status, to_status, trace_id
|
||||
) VALUES (?, ?, ?, ?, ?, 'PENDING', ?, ?)
|
||||
""".trimIndent(),
|
||||
eventId,
|
||||
tenantId,
|
||||
id,
|
||||
actorId,
|
||||
eventType,
|
||||
toStatus.name,
|
||||
traceId,
|
||||
)
|
||||
return transitioned
|
||||
}
|
||||
|
||||
private fun map(record: Record): LeaveRequest = LeaveRequest(
|
||||
id = record.get("id", UUID::class.java)!!,
|
||||
tenantId = record.get("tenant_id", UUID::class.java)!!,
|
||||
@@ -233,5 +303,7 @@ class JooqLeaveRequestRepository(
|
||||
createdAt = record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||
updatedAt = record.get("updated_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||
version = record.get("version", Long::class.java)!!,
|
||||
processInstanceId = record.get("process_instance_id", String::class.java),
|
||||
processDefinitionId = record.get("process_definition_id", String::class.java),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.all8ai.aioa.workflow.domain
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
interface LeaveWorkflowGateway {
|
||||
fun startLeaveApproval(
|
||||
tenantId: UUID,
|
||||
leaveRequestId: UUID,
|
||||
applicantId: UUID,
|
||||
approverId: UUID,
|
||||
): StartedProcess
|
||||
|
||||
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
|
||||
|
||||
fun resolveTask(taskId: String): WorkflowTask?
|
||||
|
||||
fun completeTask(taskId: String, approved: Boolean, comment: String?)
|
||||
|
||||
fun cancelProcess(processInstanceId: String, reason: String)
|
||||
}
|
||||
|
||||
data class StartedProcess(
|
||||
val processInstanceId: String,
|
||||
val processDefinitionId: String,
|
||||
)
|
||||
|
||||
data class WorkflowTask(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val processInstanceId: String,
|
||||
val leaveRequestId: UUID,
|
||||
val assigneeId: UUID,
|
||||
val createdAt: Instant,
|
||||
val completed: Boolean,
|
||||
)
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.all8ai.aioa.workflow.infrastructure
|
||||
|
||||
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||
import com.all8ai.aioa.workflow.domain.StartedProcess
|
||||
import com.all8ai.aioa.workflow.domain.WorkflowTask
|
||||
import org.flowable.engine.HistoryService
|
||||
import org.flowable.engine.RuntimeService
|
||||
import org.flowable.engine.TaskService
|
||||
import org.flowable.task.api.Task
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
class FlowableLeaveWorkflowGateway(
|
||||
private val runtimeService: RuntimeService,
|
||||
private val taskService: TaskService,
|
||||
private val historyService: HistoryService,
|
||||
) : LeaveWorkflowGateway {
|
||||
override fun startLeaveApproval(
|
||||
tenantId: UUID,
|
||||
leaveRequestId: UUID,
|
||||
applicantId: UUID,
|
||||
approverId: UUID,
|
||||
): StartedProcess {
|
||||
val process = runtimeService.createProcessInstanceBuilder()
|
||||
.processDefinitionKey(PROCESS_DEFINITION_KEY)
|
||||
.businessKey(leaveRequestId.toString())
|
||||
.variables(
|
||||
mapOf(
|
||||
"tenantId" to tenantId.toString(),
|
||||
"businessId" to leaveRequestId.toString(),
|
||||
"applicantId" to applicantId.toString(),
|
||||
"approverId" to approverId.toString(),
|
||||
),
|
||||
)
|
||||
.start()
|
||||
return StartedProcess(process.id, process.processDefinitionId)
|
||||
}
|
||||
|
||||
override fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask> =
|
||||
taskService.createTaskQuery()
|
||||
.taskAssignee(assigneeId.toString())
|
||||
.active()
|
||||
.includeProcessVariables()
|
||||
.orderByTaskCreateTime()
|
||||
.desc()
|
||||
.list()
|
||||
.map(::mapActiveTask)
|
||||
|
||||
override fun resolveTask(taskId: String): WorkflowTask? {
|
||||
taskService.createTaskQuery()
|
||||
.taskId(taskId)
|
||||
.includeProcessVariables()
|
||||
.singleResult()
|
||||
?.let { return mapActiveTask(it) }
|
||||
|
||||
val historic = historyService.createHistoricTaskInstanceQuery()
|
||||
.taskId(taskId)
|
||||
.includeProcessVariables()
|
||||
.singleResult()
|
||||
?: return null
|
||||
return WorkflowTask(
|
||||
id = historic.id,
|
||||
name = historic.name,
|
||||
processInstanceId = historic.processInstanceId,
|
||||
leaveRequestId = UUID.fromString(historic.processVariables["businessId"] as String),
|
||||
assigneeId = UUID.fromString(historic.assignee),
|
||||
createdAt = historic.createTime.toInstant(),
|
||||
completed = historic.endTime != null,
|
||||
)
|
||||
}
|
||||
|
||||
override fun completeTask(taskId: String, approved: Boolean, comment: String?) {
|
||||
if (!comment.isNullOrBlank()) {
|
||||
val task = taskService.createTaskQuery().taskId(taskId).singleResult()
|
||||
?: error("Active workflow task not found: $taskId")
|
||||
taskService.addComment(taskId, task.processInstanceId, comment.trim())
|
||||
}
|
||||
taskService.complete(taskId, mapOf("approved" to approved))
|
||||
}
|
||||
|
||||
override fun cancelProcess(processInstanceId: String, reason: String) {
|
||||
runtimeService.deleteProcessInstance(processInstanceId, reason)
|
||||
}
|
||||
|
||||
private fun mapActiveTask(task: Task): WorkflowTask = WorkflowTask(
|
||||
id = task.id,
|
||||
name = task.name,
|
||||
processInstanceId = task.processInstanceId,
|
||||
leaveRequestId = UUID.fromString(task.processVariables["businessId"] as String),
|
||||
assigneeId = UUID.fromString(task.assignee),
|
||||
createdAt = task.createTime.toInstant(),
|
||||
completed = false,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val PROCESS_DEFINITION_KEY = "leaveApproval"
|
||||
}
|
||||
}
|
||||
@@ -38,3 +38,9 @@ management:
|
||||
logging:
|
||||
pattern:
|
||||
correlation: "[traceId=%X{traceId:-}] "
|
||||
|
||||
flowable:
|
||||
database-schema: flowable
|
||||
database-schema-update: ${FLOWABLE_SCHEMA_UPDATE:true}
|
||||
async-executor-activate: false
|
||||
history-level: audit
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE business.leave_request
|
||||
ADD COLUMN process_instance_id VARCHAR(64),
|
||||
ADD COLUMN process_definition_id VARCHAR(128);
|
||||
|
||||
CREATE UNIQUE INDEX uq_leave_request_process_instance
|
||||
ON business.leave_request (process_instance_id)
|
||||
WHERE process_instance_id IS NOT NULL;
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
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">
|
||||
<startEvent id="start" name="申请已提交"/>
|
||||
<sequenceFlow id="flow-start-review" sourceRef="start" targetRef="managerReview"/>
|
||||
|
||||
<userTask id="managerReview"
|
||||
name="部门主管审批"
|
||||
flowable:assignee="${approverId}"/>
|
||||
<sequenceFlow id="flow-review-decision" sourceRef="managerReview" targetRef="decision"/>
|
||||
|
||||
<exclusiveGateway id="decision" name="审批结果"/>
|
||||
<sequenceFlow id="flow-approved" sourceRef="decision" targetRef="approvedEnd">
|
||||
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression>
|
||||
</sequenceFlow>
|
||||
<sequenceFlow id="flow-rejected" sourceRef="decision" targetRef="rejectedEnd">
|
||||
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression>
|
||||
</sequenceFlow>
|
||||
|
||||
<endEvent id="approvedEnd" name="已批准"/>
|
||||
<endEvent id="rejectedEnd" name="已驳回"/>
|
||||
</process>
|
||||
</definitions>
|
||||
+53
-1
@@ -11,6 +11,10 @@ import com.all8ai.aioa.approval.domain.OperationClaim
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
import com.all8ai.aioa.audit.domain.AuditEventRepository
|
||||
import com.all8ai.aioa.audit.domain.AuditEvent
|
||||
import com.all8ai.aioa.approval.domain.ApprovalRoutingRepository
|
||||
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||
import com.all8ai.aioa.workflow.domain.StartedProcess
|
||||
import com.all8ai.aioa.workflow.domain.WorkflowTask
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
@@ -123,7 +127,26 @@ class LeaveRequestServiceTest {
|
||||
private fun service(
|
||||
repository: LeaveRequestRepository,
|
||||
auditRepository: AuditEventRepository = AuditEventRepository { },
|
||||
) = LeaveRequestService(repository, AuditService(auditRepository))
|
||||
) = LeaveRequestService(
|
||||
repository,
|
||||
AuditService(auditRepository),
|
||||
ApprovalRoutingRepository { _, _ -> UUID.fromString("40000000-0000-7000-8000-000000000002") },
|
||||
FakeWorkflowGateway(),
|
||||
)
|
||||
|
||||
private class FakeWorkflowGateway : LeaveWorkflowGateway {
|
||||
override fun startLeaveApproval(
|
||||
tenantId: UUID,
|
||||
leaveRequestId: UUID,
|
||||
applicantId: UUID,
|
||||
approverId: UUID,
|
||||
) = StartedProcess("process-$leaveRequestId", "leaveApproval:1:test")
|
||||
|
||||
override fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask> = emptyList()
|
||||
override fun resolveTask(taskId: String): WorkflowTask? = null
|
||||
override fun completeTask(taskId: String, approved: Boolean, comment: String?) = Unit
|
||||
override fun cancelProcess(processInstanceId: String, reason: String) = Unit
|
||||
}
|
||||
|
||||
private class CapturingAuditRepository : AuditEventRepository {
|
||||
val events = mutableListOf<AuditEvent>()
|
||||
@@ -214,5 +237,34 @@ class LeaveRequestServiceTest {
|
||||
}
|
||||
|
||||
override fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List<LeaveRequestEvent> = events.toList()
|
||||
|
||||
override fun attachWorkflow(
|
||||
tenantId: UUID,
|
||||
leaveRequestId: UUID,
|
||||
processInstanceId: String,
|
||||
processDefinitionId: String,
|
||||
) {
|
||||
val entry = drafts.entries.first { it.value.leaveRequest.id == leaveRequestId }
|
||||
drafts[entry.key] = entry.value.copy(
|
||||
leaveRequest = entry.value.leaveRequest.copy(
|
||||
processInstanceId = processInstanceId,
|
||||
processDefinitionId = processDefinitionId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun findById(tenantId: UUID, id: UUID): LeaveRequest? =
|
||||
drafts.values.map { it.leaveRequest }.firstOrNull { it.tenantId == tenantId && it.id == id }
|
||||
|
||||
override fun transitionForApprover(
|
||||
tenantId: UUID,
|
||||
actorId: UUID,
|
||||
id: UUID,
|
||||
expectedVersion: Long,
|
||||
toStatus: LeaveStatus,
|
||||
eventId: UUID,
|
||||
eventType: String,
|
||||
traceId: String,
|
||||
): LeaveRequest? = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
plugins {
|
||||
id("org.springframework.boot") version "3.5.3" apply false
|
||||
id("org.springframework.boot") version "3.5.4" apply false
|
||||
id("io.spring.dependency-management") version "1.1.7" apply false
|
||||
kotlin("jvm") version "2.1.21" apply false
|
||||
kotlin("plugin.spring") version "2.1.21" apply false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AIOA API
|
||||
version: 0.3.0
|
||||
version: 0.4.0
|
||||
servers:
|
||||
- url: /api/v1
|
||||
paths:
|
||||
@@ -169,6 +169,67 @@ paths:
|
||||
$ref: "#/components/schemas/LeaveRequestEvent"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
/approval-tasks:
|
||||
get:
|
||||
operationId: listAssignedApprovalTasks
|
||||
summary: 查询分配给当前用户的审批待办
|
||||
responses:
|
||||
"200":
|
||||
description: 待办列表
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ApprovalTask"
|
||||
/approval-tasks/{taskId}/approve:
|
||||
post:
|
||||
operationId: approveTask
|
||||
summary: 批准审批任务
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ApprovalTaskId"
|
||||
- $ref: "#/components/parameters/IdempotencyKey"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ApprovalDecision"
|
||||
responses:
|
||||
"200":
|
||||
description: 已批准或幂等重放
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/LeaveRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
/approval-tasks/{taskId}/reject:
|
||||
post:
|
||||
operationId: rejectTask
|
||||
summary: 驳回审批任务
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ApprovalTaskId"
|
||||
- $ref: "#/components/parameters/IdempotencyKey"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ApprovalDecision"
|
||||
responses:
|
||||
"200":
|
||||
description: 已驳回或幂等重放
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/LeaveRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
components:
|
||||
parameters:
|
||||
LeaveRequestId:
|
||||
@@ -184,6 +245,11 @@ components:
|
||||
type: string
|
||||
minLength: 16
|
||||
maxLength: 128
|
||||
ApprovalTaskId:
|
||||
name: taskId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
BadRequest:
|
||||
description: 请求无效
|
||||
@@ -274,6 +340,10 @@ components:
|
||||
version: { type: integer, minimum: 0 }
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
processInstanceId:
|
||||
type: [string, "null"]
|
||||
processDefinitionId:
|
||||
type: [string, "null"]
|
||||
TransitionRequest:
|
||||
type: object
|
||||
required: [version]
|
||||
@@ -294,6 +364,23 @@ components:
|
||||
actorId: { type: string, format: uuid }
|
||||
traceId: { type: string }
|
||||
occurredAt: { type: string, format: date-time }
|
||||
ApprovalDecision:
|
||||
type: object
|
||||
required: [version]
|
||||
properties:
|
||||
version: { type: integer, format: int64, minimum: 0 }
|
||||
comment:
|
||||
type: [string, "null"]
|
||||
maxLength: 1000
|
||||
ApprovalTask:
|
||||
type: object
|
||||
required: [id, name, createdAt, leaveRequest]
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
createdAt: { type: string, format: date-time }
|
||||
leaveRequest:
|
||||
$ref: "#/components/schemas/LeaveRequest"
|
||||
Problem:
|
||||
type: object
|
||||
required: [type, title, status, code, traceId]
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
- [x] 草稿创建、修改、本人查询与列表 API
|
||||
- [x] 创建幂等、租户/用户隔离和乐观锁
|
||||
- [x] 提交、撤回、状态时间线和同步审计
|
||||
- Flowable 流程发布与执行
|
||||
- 发起、待办、批准、驳回、撤回和时间线
|
||||
- [x] Flowable 部门主管审批流程发布与执行
|
||||
- [x] 发起、主管待办、批准、驳回、撤回和时间线
|
||||
- 附件、通知、弱网恢复和幂等处理
|
||||
|
||||
## M3:AI 最小闭环
|
||||
|
||||
Reference in New Issue
Block a user